diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index c8849a8f..560d4f6a 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -105,7 +105,10 @@ function createEmptyAppointment(): AppointmentDraft { tenantId: "", tenantName: "", tenantSlug: "", + isPrimary: false, isOwner: false, + isAdmin: false, + isManager: false, grade: "", jobTitle: "", position: "", @@ -314,8 +317,8 @@ function UserCreatePage() { if (currentIndex === index) { return { ...appointment, ...patch }; } - if (patch.isOwner === true) { - return { ...appointment, isOwner: false }; + if (patch.isPrimary === true) { + return { ...appointment, isPrimary: false }; } return appointment; }), @@ -425,8 +428,10 @@ function UserCreatePage() { tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, - isPrimary: appointment.isOwner, - isOwner: appointment.isOwner, + isPrimary: appointment.isPrimary === true, + ...(appointment.isOwner === true ? { isOwner: true } : {}), + ...(appointment.isAdmin === true ? { isAdmin: true } : {}), + ...(appointment.isManager === true ? { isManager: true } : {}), grade: appointment.grade, jobTitle: appointment.jobTitle, position: appointment.position, @@ -442,12 +447,11 @@ function UserCreatePage() { return; } - const primary = appointments.find((a) => a.isOwner); + const primary = appointments.find((a) => a.isPrimary); if (primary) { metadata.primaryTenantId = primary.tenantId; metadata.primaryTenantSlug = primary.tenantSlug; metadata.primaryTenantName = primary.tenantName; - metadata.primaryTenantIsOwner = true; } payload.additionalAppointments = appointments; @@ -811,10 +815,10 @@ function UserCreatePage() { )} + diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 89f2d981..21b82a9a 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -138,6 +138,8 @@ function createEmptyAppointment(): AppointmentDraft { tenantSlug: "", isPrimary: false, isOwner: false, + isAdmin: false, + isManager: false, grade: "", jobTitle: "", position: "", @@ -551,8 +553,8 @@ function UserDetailPage() { if (currentIndex === index) { return { ...appointment, ...patch }; } - if (patch.isOwner === true) { - return { ...appointment, isOwner: false }; + if (patch.isPrimary === true) { + return { ...appointment, isPrimary: false }; } return appointment; }), @@ -663,6 +665,9 @@ function UserDetailPage() { isPrimary: appointment.isPrimary === true || appointment.tenantId === primaryFromMetadata?.id, + isOwner: appointment.isOwner === true, + isAdmin: appointment.isAdmin === true, + isManager: appointment.isManager === true, draftId: createDraftId(), })) : isUserHanmacFamily @@ -676,6 +681,8 @@ function UserDetailPage() { isOwner: metadata.primaryTenantIsOwner === true && tenant.id === fallbackAppointment?.id, + isAdmin: false, + isManager: false, grade: user.grade, jobTitle: user.jobTitle, position: user.position, @@ -689,6 +696,8 @@ function UserDetailPage() { tenantSlug: fallbackAppointment.slug, isPrimary: true, isOwner: metadata.primaryTenantIsOwner === true, + isAdmin: false, + isManager: false, grade: user.grade, jobTitle: user.jobTitle, position: user.position, @@ -779,23 +788,23 @@ function UserDetailPage() { tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, - isPrimary: appointment.isOwner, - isOwner: appointment.isOwner, + isPrimary: appointment.isPrimary === true, + ...(appointment.isOwner === true ? { isOwner: true } : {}), + ...(appointment.isAdmin === true ? { isAdmin: true } : {}), + ...(appointment.isManager === true ? { isManager: true } : {}), grade: appointment.grade, jobTitle: appointment.jobTitle, position: appointment.position, })); - const primary = appointments.find((a) => a.isOwner); + const primary = appointments.find((a) => a.isPrimary); if (primary) { payload.tenantSlug = primary.tenantSlug; payload.primaryTenantId = primary.tenantId; payload.primaryTenantName = primary.tenantName; - payload.primaryTenantIsOwner = true; metadata.primaryTenantId = primary.tenantId; metadata.primaryTenantSlug = primary.tenantSlug; metadata.primaryTenantName = primary.tenantName; - metadata.primaryTenantIsOwner = true; } else { payload.tenantSlug = undefined; } @@ -811,12 +820,10 @@ function UserDetailPage() { primaryTenantId: primary?.tenantId, primaryTenantName: primary?.tenantName, primaryTenantSlug: primary?.tenantSlug, - primaryTenantIsOwner: primary?.isOwner ?? false, }; payload.tenantSlug = primary?.tenantSlug; payload.primaryTenantId = primary?.tenantId; payload.primaryTenantName = primary?.tenantName; - payload.primaryTenantIsOwner = primary?.isOwner ?? false; } mutation.mutate(payload); @@ -1221,13 +1228,13 @@ function UserDetailPage() { )} + diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx new file mode 100644 index 00000000..f0b7f567 --- /dev/null +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -0,0 +1,148 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import UserListPage from "./UserListPage"; + +const selectRenderCounter = vi.hoisted(() => ({ count: 0 })); + +const users = Array.from({ length: 200 }, (_, index) => ({ + id: `user-${index}`, + name: `User ${index}`, + email: `user${index}@example.com`, + phone: `010-${String(index).padStart(4, "0")}-0000`, + role: "user", + status: "active", + tenantSlug: "hanmac", + tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" }, + metadata: {}, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", +})); + +const fetchUsersMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "admin-user", + role: "super_admin", + name: "Admin", + email: "admin@example.com", + })), + fetchAllTenants: vi.fn(async () => ({ + items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }], + total: 1, + })), + fetchTenant: vi.fn(async () => ({ + id: "tenant-1", + name: "한맥", + slug: "hanmac", + config: { userSchema: [] }, + })), + fetchUsers: fetchUsersMock, + bulkCreateUsers: vi.fn(), + bulkDeleteUsers: vi.fn(), + bulkUpdateUsers: vi.fn(), + deleteUser: vi.fn(), + exportUsersCSV: vi.fn(), + updateUser: vi.fn(), +})); + +vi.mock("../../components/ui/select", () => ({ + Select: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectTrigger: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => { + selectRenderCounter.count += 1; + return ( + + ); + }, + SelectValue: () => , + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ + children, + value: _value, + }: { + children: React.ReactNode; + value: string; + }) =>
{children}
, +})); + +function renderUserListPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + + + , + ); +} + +describe("UserListPage search rendering", () => { + beforeEach(() => { + selectRenderCounter.count = 0; + fetchUsersMock.mockReset(); + fetchUsersMock.mockImplementation( + async ( + _limit: number, + _offset: number, + search?: string, + ) => { + const normalizedSearch = search?.trim().toLowerCase(); + const items = normalizedSearch + ? users.filter((user) => + `${user.name} ${user.email}` + .toLowerCase() + .includes(normalizedSearch), + ) + : users; + return { items, total: items.length }; + }, + ); + }); + + it("does not rerender user table controls while typing a draft search", async () => { + renderUserListPage(); + + await screen.findByText("User 199"); + const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); + const renderCountBeforeTyping = selectRenderCounter.count; + + fireEvent.change(searchInput, { target: { value: "u" } }); + + expect(searchInput).toHaveValue("u"); + expect(selectRenderCounter.count).toBe(renderCountBeforeTyping); + }); + + it("renders a 200-user search result update within 200ms after search submit", async () => { + renderUserListPage(); + + await screen.findByText("User 199"); + const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); + const startedAt = performance.now(); + + fireEvent.change(searchInput, { target: { value: "user 19" } }); + fireEvent.keyDown(searchInput, { key: "Enter" }); + + await screen.findByText("User 19"); + await waitFor(() => { + expect(screen.queryByText("User 0")).not.toBeInTheDocument(); + }); + expect(performance.now() - startedAt).toBeLessThan(200); + }); +}); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 6c63cc1d..cdacc668 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -81,6 +81,7 @@ import { } from "../../components/ui/table"; import { toast } from "../../components/ui/use-toast"; import { + type TenantSummary, type UserSummary, bulkDeleteUsers, bulkUpdateUsers, @@ -130,11 +131,115 @@ function assignableSystemRoleValue(role?: string | null) { return isSuperAdminRole(role) ? "super_admin" : "user"; } +function userMatchesSearch(user: UserSummary, search: string) { + const normalizedSearch = search.trim().toLowerCase(); + if (!normalizedSearch) { + return true; + } + + return [ + user.name, + user.email, + user.phone, + user.id, + user.tenantSlug, + user.tenant?.name, + user.department, + ].some((value) => value?.toLowerCase().includes(normalizedSearch)); +} + +type UserListSearchControlsProps = { + search: string; + selectedCompany: string; + tenants: TenantSummary[]; + profileRole?: string | null; + onSearch: (value: string) => void; + onCompanyChange: (value: string) => void; +}; + +const UserListSearchControls = React.memo(function UserListSearchControls({ + search, + selectedCompany, + tenants, + profileRole, + onSearch, + onCompanyChange, +}: UserListSearchControlsProps) { + const [searchDraft, setSearchDraft] = React.useState(search); + + React.useEffect(() => { + setSearchDraft(search); + }, [search]); + + const handleSearch = React.useCallback(() => { + onSearch(searchDraft); + }, [onSearch, searchDraft]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + handleSearch(); + } + }, + [handleSearch], + ); + + const tenantOptions = React.useMemo( + () => + tenants.map((tenant) => ( + + )), + [tenants], + ); + + return ( + +
+ + setSearchDraft(event.target.value)} + onKeyDown={handleKeyDown} + /> +
+ + + + + + } + /> + ); +}); + function UserListPage() { const navigate = useNavigate(); const [page, setPage] = React.useState(1); const [search, setSearch] = React.useState(""); - const [searchDraft, setSearchDraft] = React.useState(""); const [selectedCompany, setSelectedCompany] = React.useState(""); const [visibleColumns, setVisibleColumns] = React.useState< Record @@ -254,16 +359,15 @@ function UserListPage() { }, }); - const handleSearch = () => { - setSearch(searchDraft); + const handleSearch = React.useCallback((nextSearch: string) => { + setSearch(nextSearch); setPage(1); - }; + }, []); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleSearch(); - } - }; + const handleCompanyChange = React.useCallback((nextCompany: string) => { + setSelectedCompany(nextCompany); + setPage(1); + }, []); const handleExport = (includeIds = false) => { exportMutation.mutate(includeIds); @@ -279,7 +383,14 @@ function UserListPage() { ) : null; - const rawItems = query.data?.items ?? []; + const serverItems = query.data?.items ?? []; + const rawItems = React.useMemo(() => { + if (!query.isFetching || search.trim() === "") { + return serverItems; + } + + return serverItems.filter((user) => userMatchesSearch(user, search)); + }, [query.isFetching, search, serverItems]); const userSortResolvers = React.useMemo< SortResolverMap >( @@ -436,52 +547,13 @@ function UserListPage() { )} actions={ <> - -
- - setSearchDraft(e.target.value)} - onKeyDown={handleKeyDown} - /> -
- - - - - - } +