From 47d2f152837c0ede9d18e59625a501cfaebff986 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 4 Jun 2026 13:08:11 +0900 Subject: [PATCH 01/17] =?UTF-8?q?tenants=20=EB=AA=A9=EB=A1=9D=20=ED=88=B4?= =?UTF-8?q?=EB=B0=94=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 340 +++++++++--------- 1 file changed, 177 insertions(+), 163 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index cc3e8af1..96646a18 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -33,6 +33,7 @@ import { sortItems, toggleSort, } from "../../../../../common/core/utils"; +import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; @@ -708,174 +709,187 @@ function TenantListPage() { "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )} actions={ - <> -
-
- - setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - query.refetch(); - } - }} - /> -
+
+ +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + query.refetch(); + } + }} + /> +
-
- - -
- - - {scopeTenantId ? ( - - ) : null} - - - - - +
- - - setViewMode("table")} + data-testid="tenant-view-table-btn" > - - {t("ui.admin.tenants.csv_template", "템플릿 다운로드")} - - - fileInputRef.current?.click()} - disabled={importMutation.isPending} - data-testid="tenant-import-menu-item" - className="cursor-pointer" - > - - {t("ui.admin.tenants.import", "CSV 가져오기")} - - - exportMutation.mutate(false)} - disabled={exportMutation.isPending} - data-testid="tenant-export-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_without_ids", - "UUID 제외 내보내기", - )} - - exportMutation.mutate(true)} - disabled={exportMutation.isPending} - data-testid="tenant-export-with-ids-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_with_ids", - "UUID 포함 내보내기", - )} - - - - + + {t("ui.admin.tenants.view.table", "평면")} + +
- - - - -
+ + {scopeTenantId ? ( + + ) : null} + + } + actions={ + <> + + + + + + + + + + {t( + "ui.admin.tenants.csv_template", + "템플릿 다운로드", + )} + + + fileInputRef.current?.click()} + disabled={importMutation.isPending} + data-testid="tenant-import-menu-item" + className="cursor-pointer" + > + + {t("ui.admin.tenants.import", "CSV 가져오기")} + + + exportMutation.mutate(false)} + disabled={exportMutation.isPending} + data-testid="tenant-export-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_without_ids", + "UUID 제외 내보내기", + )} + + exportMutation.mutate(true)} + disabled={exportMutation.isPending} + data-testid="tenant-export-with-ids-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_with_ids", + "UUID 포함 내보내기", + )} + + + + + + + + + + + } + /> {importMessage ? (
) : null} - +
} /> From f6c7cb3b225e727df197a5c957a25022730421a6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 4 Jun 2026 15:15:05 +0900 Subject: [PATCH 02/17] =?UTF-8?q?tenants=20=EB=A0=88=EC=A7=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EA=B0=80=EB=8F=85=EC=84=B1/=EB=A1=9C?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 96 +++++++++++++------ adminfront/src/locales/en.toml | 10 ++ adminfront/src/locales/ko.toml | 18 +++- locales/en.toml | 15 +++ locales/ko.toml | 23 ++++- locales/template.toml | 14 +++ 6 files changed, 138 insertions(+), 38 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 96646a18..6d99ddae 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -69,7 +69,6 @@ import { SelectTrigger, SelectValue, } from "../../../components/ui/select"; -import { Switch } from "../../../components/ui/switch"; import { Table, TableBody, @@ -80,7 +79,6 @@ import { } from "../../../components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; -import type { UserProfileResponse } from "../../../lib/adminApi"; import { deleteTenantsBulk, exportTenantsCSV, @@ -142,6 +140,51 @@ const getTenantIcon = (type?: string) => { } }; +function getTenantTypeLabel(type?: string) { + if (!type) return "-"; + return t(`domain.tenant_type.${type.toLowerCase()}`, type); +} + +function getTenantTypeTextClass(type?: string) { + switch (type?.toUpperCase()) { + case "COMPANY_GROUP": + return "text-sky-700"; + case "COMPANY": + return "text-violet-700"; + case "ORGANIZATION": + return "text-emerald-700"; + case "USER_GROUP": + return "text-amber-700"; + case "PERSONAL": + return "text-slate-700"; + default: + return "text-muted-foreground"; + } +} + +function buildTenantParentPathMap(tenants: TenantSummary[]) { + const tenantById = new Map(tenants.map((tenant) => [tenant.id, tenant])); + const pathMap = new Map(); + + for (const tenant of tenants) { + const names: string[] = []; + const visited = new Set(); + let currentParentId = tenant.parentId; + + while (currentParentId && !visited.has(currentParentId)) { + visited.add(currentParentId); + const parent = tenantById.get(currentParentId); + if (!parent) break; + names.unshift(parent.name); + currentParentId = parent.parentId; + } + + pathMap.set(tenant.id, names); + } + + return pathMap; +} + const noImportParentRef = "__none__"; function tenantParentRef(tenantId: string) { @@ -339,19 +382,6 @@ function TenantListPage() { }, }); - const statusMutation = useMutation({ - mutationFn: ({ tenantId, status }: { tenantId: string; status: string }) => - updateTenant(tenantId, { status }), - onSuccess: () => { - query.refetch(); - }, - onError: () => { - toast.error( - t("msg.admin.tenants.status_error", "테넌트 상태 변경에 실패했습니다."), - ); - }, - }); - const bulkUpdateStatusMutation = useMutation({ mutationFn: async ({ tenantIds, @@ -935,8 +965,6 @@ function TenantListPage() { onSelectAll={handleSelectAll} search={search} deletableTenants={deletableTenants} - statusMutation={statusMutation} - profile={profile} sortConfig={sortConfig} requestSort={requestSort} getSortIcon={getSortIcon} @@ -1513,13 +1541,6 @@ const TenantHierarchyView: React.FC<{ onSelectAll: (checked: boolean) => void; search: string; deletableTenants: TenantSummary[]; - statusMutation: UseMutationResult< - TenantSummary, - Error, - { tenantId: string; status: string }, - unknown - >; - profile: UserProfileResponse | undefined; sortConfig: SortConfig | null; requestSort: (key: TenantSortKey) => void; getSortIcon: (key: TenantSortKey) => React.ReactNode; @@ -1536,8 +1557,6 @@ const TenantHierarchyView: React.FC<{ onSelectAll, search, deletableTenants, - statusMutation, - profile, sortConfig, requestSort, getSortIcon, @@ -1558,6 +1577,10 @@ const TenantHierarchyView: React.FC<{ () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search), [scopeTenantId, tenants, search], ); + const tenantParentPathMap = React.useMemo( + () => buildTenantParentPathMap(tenants), + [tenants], + ); // Initial expanded state: everything open const [expandedIds, setExpandedIds] = React.useState>(() => { @@ -1682,6 +1705,22 @@ const TenantHierarchyView: React.FC<{ const visibleSelectedCount = selectedIds.filter((id) => visibleSelectableIds.has(id), ).length; + const normalizedSearch = search.trim(); + const emptyMessage = React.useMemo(() => { + if (normalizedSearch) { + return t( + "msg.admin.tenants.empty_search", + "검색 조건에 맞는 테넌트가 없습니다.", + ); + } + if (scopeTenantId) { + return t( + "msg.admin.tenants.empty_scope", + "선택한 범위에 표시할 하위 테넌트가 없습니다.", + ); + } + return t("msg.admin.tenants.empty", "아직 등록된 테넌트가 없습니다."); + }, [normalizedSearch, scopeTenantId]); const renderRow = ( node: TenantViewRow, @@ -1923,10 +1962,7 @@ const TenantHierarchyView: React.FC<{ colSpan={8} className="py-8 text-center text-muted-foreground" > - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} + {emptyMessage} )} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index ffb10bb1..abe23e4f 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1071,6 +1071,7 @@ user = "General User (Tenant Member)" [ui.admin.tenants] add = "Add Tenant" csv_template = "Template" +data_mgmt = "Data Management" delete_selected = "Delete Selected" export_with_ids = "Include UUIDs" export_without_ids = "Export without UUIDs" @@ -1267,6 +1268,15 @@ name = "NAME" slug = "SLUG" status = "STATUS" +[ui.admin.tenants.view] +list = "List" +table = "Table" +tree = "Tree" + +[ui.admin.tenants.scope] +active = "{{name}} descendants" +pick = "Select parent scope" + [ui.admin.tenants.table] actions = "ACTIONS" id = "ID" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 03f6502b..8f9d3694 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1074,6 +1074,7 @@ user = "일반 사용자 (Tenant Member)" [ui.admin.tenants] add = "테넌트 추가" csv_template = "템플릿" +data_mgmt = "데이터 관리" delete_selected = "선택 삭제" export_with_ids = "UUID 포함" export_without_ids = "UUID 제외 내보내기" @@ -1270,15 +1271,24 @@ name = "NAME" slug = "SLUG" status = "STATUS" +[ui.admin.tenants.view] +list = "평면 목록" +table = "평면" +tree = "트리" + +[ui.admin.tenants.scope] +active = "{{name}} 하위" +pick = "상위 범위 선택" + [ui.admin.tenants.table] actions = "ACTIONS" id = "ID" members = "멤버수" -name = "NAME" -slug = "SLUG" -status = "STATUS" +name = "이름" +slug = "슬러그" +status = "상태" type = "유형" -updated = "UPDATED" +updated = "수정일" [ui.admin.users] csv_template = "템플릿 다운로드" diff --git a/locales/en.toml b/locales/en.toml index 6c33882b..215edaf2 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -264,6 +264,15 @@ subtitle = "List of owners with top-level permissions for this tenant." [msg.admin.tenants.registry] count = "{{count}} tenants loaded." +scope_results = "{{count}} tenants under {{name}}" +scope_search_results = "{{count}} search results under {{name}}" +search_results = "{{count}} search results" +table_hint = "Compare IDs, status, and size quickly in the sortable flat list." +tree_hint = "Review parent-child relationships and subtree coverage in the hierarchy." + +[msg.admin.tenants] +empty_scope = "There are no child tenants to display in the selected scope." +empty_search = "No tenants match the current search." [msg.admin.tenants.schema] empty = "No custom fields defined. Click \\\\\\\"Add Field\\\\\\\" to begin." @@ -1157,6 +1166,7 @@ user = "TENANT MEMBER" [ui.admin.tenants] add = "Add Tenant" csv_template = "Template" +data_mgmt = "Data Management" delete_selected = "Delete Selected" export_with_ids = "Include UUIDs" export_without_ids = "Export without UUIDs" @@ -1441,9 +1451,14 @@ status = "STATUS" [ui.admin.tenants.table] actions = "ACTIONS" +context = "Parent Path" id = "ID" +id_copy = "Copy ID" members = "Members" +members_count = "{{count}} members" +members_recursive = "including descendants" name = "NAME" +root = "Top Level" slug = "SLUG" status = "STATUS" type = "TYPE" diff --git a/locales/ko.toml b/locales/ko.toml index f2655ee2..f9a0f288 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -765,6 +765,15 @@ subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목 [msg.admin.tenants.registry] count = "총 {{count}}개 테넌트" +scope_results = "{{name}} 하위 {{count}}개" +scope_search_results = "{{name}} 하위 검색 결과 {{count}}개" +search_results = "검색 결과 {{count}}개" +table_hint = "정렬 가능한 평면 목록에서 ID, 상태, 규모를 빠르게 비교합니다." +tree_hint = "계층 구조를 따라 부모-자식 관계와 하위 범위를 함께 확인합니다." + +[msg.admin.tenants] +empty_scope = "선택한 범위에 표시할 하위 테넌트가 없습니다." +empty_search = "검색 조건에 맞는 테넌트가 없습니다." [msg.admin.tenants.schema] empty = "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요." @@ -1652,6 +1661,7 @@ user = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" +data_mgmt = "데이터 관리" delete_selected = "선택 삭제" seed_badge = "초기 설정" title = "테넌트 목록" @@ -1904,13 +1914,18 @@ status = "STATUS" [ui.admin.tenants.table] actions = "ACTIONS" +context = "상위 경로" id = "ID" +id_copy = "ID 복사" members = "멤버수" -name = "NAME" -slug = "SLUG" -status = "STATUS" +members_count = "{{count}}명" +members_recursive = "하위 포함" +name = "이름" +root = "최상위" +slug = "슬러그" +status = "상태" type = "유형" -updated = "UPDATED" +updated = "수정일" created = "CREATED" created = "CREATED" diff --git a/locales/template.toml b/locales/template.toml index eac89aca..c0f26def 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -622,6 +622,15 @@ subtitle = "" [msg.admin.tenants.registry] count = "" +scope_results = "" +scope_search_results = "" +search_results = "" +table_hint = "" +tree_hint = "" + +[msg.admin.tenants] +empty_scope = "" +empty_search = "" [msg.admin.tenants.schema] empty = "" @@ -1781,9 +1790,14 @@ status = "" [ui.admin.tenants.table] actions = "" +context = "" id = "" +id_copy = "" members = "" +members_count = "" +members_recursive = "" name = "" +root = "" slug = "" status = "" type = "" From 1596342d031d1b1f9d85b1f7ab9d613a62d8868c Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 4 Jun 2026 15:56:33 +0900 Subject: [PATCH 03/17] =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20?= =?UTF-8?q?=EC=A0=91=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.test.tsx | 16 ++++ .../src/components/layout/AppLayout.tsx | 52 ++++++++++-- adminfront/src/locales/en.toml | 4 + adminfront/src/locales/ko.toml | 4 + adminfront/src/locales/template.toml | 4 + common/shell/AppSidebar.tsx | 70 ++++++++++++++-- common/shell/index.ts | 21 +++++ common/shell/layout.ts | 17 ++++ .../src/components/layout/AppLayout.test.tsx | 18 +++++ devfront/src/components/layout/AppLayout.tsx | 80 ++++++++++++++----- devfront/src/locales/en.toml | 4 + devfront/src/locales/ko.toml | 4 + devfront/src/locales/template.toml | 4 + locales/en.toml | 4 + locales/ko.toml | 4 + locales/template.toml | 4 + 16 files changed, 277 insertions(+), 33 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx index 366e3f10..6634a80a 100644 --- a/adminfront/src/components/layout/AppLayout.test.tsx +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -127,6 +127,22 @@ describe("admin AppLayout", () => { expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull(); }); + it("toggles the sidebar and persists the collapsed state", async () => { + renderLayout(); + + const collapseButton = await screen.findByRole("button", { + name: "사이드바 접기", + }); + fireEvent.click(collapseButton); + + expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe( + "true", + ); + expect( + screen.getByRole("button", { name: "사이드바 펼치기" }), + ).toBeInTheDocument(); + }); + it("opens profile menu, navigates, toggles theme/session, and logs out", async () => { renderLayout(); diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 23d8a065..6ba74cb6 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -26,11 +26,13 @@ import { buildShellProfileSummary, buildShellSessionStatus, readShellSessionExpiryEnabled, + readShellSidebarCollapsed, readShellTheme, type ShellSidebarNavItem, type ShellTranslator, shellLayoutClasses, writeShellSessionExpiryEnabled, + writeShellSidebarCollapsed, } from "../../../../common/shell"; import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; @@ -165,6 +167,9 @@ function AppLayout() { const isDevelopmentRuntime = import.meta.env.MODE === "development"; const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => + readShellSidebarCollapsed(false), + ); const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); @@ -508,10 +513,18 @@ function AppLayout() { return next; }); }; + const handleSidebarToggle = () => { + setIsSidebarCollapsed((prev) => { + const next = !prev; + writeShellSidebarCollapsed(next); + return next; + }); + }; const sidebarNavContent = (
{navItems.map((item) => { const { labelKey, labelFallback, to, icon: Icon, isExternal } = item; + const label = t(labelKey, labelFallback); if (isExternal) { return ( @@ -522,11 +535,18 @@ function AppLayout() { rel="noopener noreferrer" className={[ shellLayoutClasses.navItemBase, + isSidebarCollapsed + ? shellLayoutClasses.navItemBaseCollapsed + : "", shellLayoutClasses.navItemIdle, ].join(" ")} + title={label} + aria-label={label} > - {t(labelKey, labelFallback)} + + {label} + ); } @@ -539,6 +559,9 @@ function AppLayout() { className={({ isActive }) => [ shellLayoutClasses.navItemBase, + isSidebarCollapsed + ? shellLayoutClasses.navItemBaseCollapsed + : "", item.isActive !== undefined ? item.isActive ? shellLayoutClasses.navItemActive @@ -548,9 +571,11 @@ function AppLayout() { : shellLayoutClasses.navItemIdle, ].join(" ") } + title={label} + aria-label={label} > - {t(labelKey, labelFallback)} + {label} ); })} @@ -561,10 +586,17 @@ function AppLayout() {
); @@ -578,13 +610,23 @@ function AppLayout() { } return ( -
+
} navContent={sidebarNavContent} footerContent={sidebarFooterContent} + collapsed={isSidebarCollapsed} + onToggleCollapsed={handleSidebarToggle} + collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")} + expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")} />
diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index abe23e4f..bdac84f5 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1541,6 +1541,10 @@ unknown_name = "Unknown User" logout = "Logout" profile = "My Profile" +[ui.shell.sidebar] +collapse = "Collapse sidebar" +expand = "Expand sidebar" + [ui.shell.role] rp_admin = "Service Administrator (RP Admin)" super_admin = "System Administrator (Super Admin)" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 8f9d3694..81cd50bf 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1544,6 +1544,10 @@ unknown_name = "Unknown User" logout = "Logout" profile = "내 정보" +[ui.shell.sidebar] +collapse = "사이드바 접기" +expand = "사이드바 펼치기" + [ui.shell.role] rp_admin = "서비스 관리자 (RP Admin)" super_admin = "시스템 관리자 (Super Admin)" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 83582b7d..444bff20 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1513,6 +1513,10 @@ unknown_name = "" logout = "" profile = "" +[ui.shell.sidebar] +collapse = "" +expand = "" + [ui.shell.role] rp_admin = "" super_admin = "" diff --git a/common/shell/AppSidebar.tsx b/common/shell/AppSidebar.tsx index d49f56cc..30ef40f5 100644 --- a/common/shell/AppSidebar.tsx +++ b/common/shell/AppSidebar.tsx @@ -1,3 +1,4 @@ +import { Menu, SquareMenu } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; import { shellLayoutClasses } from "./layout"; @@ -14,9 +15,13 @@ export type ShellSidebarNavItem = { type ShellSidebarProps = { brandLabel: string; brandTitle: string; - brandIcon: ReactNode; + brandIcon?: ReactNode; navContent: ReactNode; footerContent: ReactNode; + collapsed?: boolean; + onToggleCollapsed?: () => void; + collapseLabel?: string; + expandLabel?: string; }; export function AppSidebar({ @@ -25,14 +30,57 @@ export function AppSidebar({ brandIcon, navContent, footerContent, + collapsed = false, + onToggleCollapsed, + collapseLabel = "Collapse sidebar", + expandLabel = "Expand sidebar", }: ShellSidebarProps) { return ( -