1
0
forked from baron/baron-sso

조직도 표현 개선

This commit is contained in:
2026-05-29 10:33:15 +09:00
parent 6a6730b544
commit c489c7c38f
34 changed files with 1872 additions and 391 deletions

View File

@@ -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() {
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isOwner}
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isOwner: checked === true,
isPrimary: checked === true,
})
}
aria-label={t(
@@ -827,6 +831,24 @@ function UserCreatePage() {
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</div>
</div>

View File

@@ -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() {
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isOwner}
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isOwner: checked === true,
isPrimary: checked === true,
})
}
disabled={appointment.isPrimary}
disabled={appointment.isPrimary === true}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
@@ -1238,6 +1245,24 @@ function UserDetailPage() {
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</div>
</div>

View File

@@ -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 }) => (
<div>{children}</div>
),
SelectTrigger: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
selectRenderCounter.count += 1;
return (
<button type="button" {...props}>
{children}
</button>
);
},
SelectValue: () => <span />,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value: _value,
}: {
children: React.ReactNode;
value: string;
}) => <div>{children}</div>,
}));
function renderUserListPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<UserListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
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);
});
});

View File

@@ -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<HTMLInputElement>) => {
if (event.key === "Enter") {
handleSearch();
}
},
[handleSearch],
);
const tenantOptions = React.useMemo(
() =>
tenants.map((tenant) => (
<option key={tenant.id} value={tenant.slug}>
{tenant.name}
</option>
)),
[tenants],
);
return (
<SearchFilterBar
primary={
<>
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="h-9 pl-9"
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(event) => onCompanyChange(event.target.value)}
disabled={profileRole === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenantOptions}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</>
}
/>
);
});
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<string>("");
const [visibleColumns, setVisibleColumns] = React.useState<
Record<string, boolean>
@@ -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<UserSummary, UserSortKey>
>(
@@ -436,52 +547,13 @@ function UserListPage() {
)}
actions={
<>
<SearchFilterBar
primary={
<>
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="h-9 pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">
{t("ui.common.all", "전체 테넌트")}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</>
}
<UserListSearchControls
search={search}
selectedCompany={selectedCompany}
tenants={tenants}
profileRole={profile?.role}
onSearch={handleSearch}
onCompanyChange={handleCompanyChange}
/>
<Button

View File

@@ -206,6 +206,12 @@ function cleanAdditionalAppointment(
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.isAdmin !== undefined
? { isAdmin: appointment.isAdmin }
: {}),
...(appointment.isManager !== undefined
? { isManager: appointment.isManager }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),