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..929a0846 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", "사이드바 펼치기")} />
@@ -785,7 +827,7 @@ function AppLayout() {
- +
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index cc3e8af1..8f9900fe 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,9 +1,4 @@ -import { - type UseMutationResult, - useInfiniteQuery, - useMutation, - useQuery, -} from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { @@ -25,7 +20,7 @@ import { Upload, } from "lucide-react"; import * as React from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useOutletContext } from "react-router-dom"; import { PageHeader } from "../../../../../common/core/components/page"; import { type SortConfig, @@ -33,6 +28,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"; @@ -68,7 +64,6 @@ import { SelectTrigger, SelectValue, } from "../../../components/ui/select"; -import { Switch } from "../../../components/ui/switch"; import { Table, TableBody, @@ -79,7 +74,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, @@ -124,6 +118,10 @@ const tenantCSVTemplate = const tenantPageSize = 500; const _tenantVirtualizationThreshold = 250; const _tenantEstimatedRowHeight = 73; +const tenantTableHeadClassName = + "h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap"; +const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`; +const tenantTableHeadContentClassName = "flex h-full items-center gap-1"; const _tenantLoadAheadPx = 360; const _tenantLoadAheadRows = 30; @@ -141,6 +139,70 @@ const getTenantIcon = (type?: string) => { } }; +function getTenantTypeLabel(type?: string) { + if (!type) return "-"; + return t(`domain.tenant_type.${type.toLowerCase()}`, type); +} + +function splitTenantTypeLabel(label: string) { + const match = label.match(/^(.*?)\s*(\(.+\))$/); + if (!match) { + return { primary: label, secondary: null as string | null }; + } + return { + primary: match[1].trim(), + secondary: match[2].trim(), + }; +} + +function abbreviateUuid(value: string) { + const parts = value.split("-"); + if (parts.length < 4) { + return value; + } + return `${parts.slice(0, 4).join("-")}-...`; +} + +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) { @@ -338,19 +400,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, @@ -450,7 +499,6 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenantTotal = query.data?.pages[0]?.total ?? 0; const hanmacFamilyTenantId = React.useMemo(() => { const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; if (typeof envTenantId === "string" && envTenantId.trim()) { @@ -708,174 +756,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} - +
} /> @@ -900,7 +961,9 @@ function TenantListPage() { "msg.admin.tenants.registry.count", "총 {{count}}개의 테넌트가 등록되어 있습니다.", { - count: scopeTenantId ? scopedTenants.length : tenantTotal, + count: scopeTenantId + ? scopedTenants.length + : allTenants.length, }, )} @@ -921,8 +984,6 @@ function TenantListPage() { onSelectAll={handleSelectAll} search={search} deletableTenants={deletableTenants} - statusMutation={statusMutation} - profile={profile} sortConfig={sortConfig} requestSort={requestSort} getSortIcon={getSortIcon} @@ -1499,13 +1560,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; @@ -1522,8 +1576,6 @@ const TenantHierarchyView: React.FC<{ onSelectAll, search, deletableTenants, - statusMutation, - profile, sortConfig, requestSort, getSortIcon, @@ -1535,15 +1587,28 @@ const TenantHierarchyView: React.FC<{ isLoading, }) => { const parentRef = React.useRef(null); + const isSidebarCollapsed = useOutletContext() ?? false; const isTest = (typeof process !== "undefined" && process.env.NODE_ENV === "test") || (typeof window !== "undefined" && (window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE); + const tenantTableGridTemplateColumns = React.useMemo( + () => + isSidebarCollapsed + ? "48px minmax(380px, 1fr) 310px 140px 240px 120px 120px 110px" + : "48px minmax(500px, 1fr) 240px 130px 226px 100px 100px 110px", + [isSidebarCollapsed], + ); + const tenantTableMinWidth = "100%"; const { subTree } = React.useMemo( () => 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>(() => { @@ -1639,6 +1704,7 @@ const TenantHierarchyView: React.FC<{ }); const virtualRows = rowVirtualizer.getVirtualItems(); + const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100); React.useEffect(() => { if (isTest) return; @@ -1668,6 +1734,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, @@ -1693,8 +1775,19 @@ const TenantHierarchyView: React.FC<{ )} style={ virtualRow - ? { transform: `translateY(${virtualRow.start}px)` } - : undefined + ? { + display: "grid", + gridTemplateColumns: tenantTableGridTemplateColumns, + minWidth: tenantTableMinWidth, + position: "absolute", + transform: `translateY(${virtualRow.start}px)`, + width: "100%", + } + : { + display: "grid", + gridTemplateColumns: tenantTableGridTemplateColumns, + minWidth: tenantTableMinWidth, + } } > @@ -1742,182 +1835,249 @@ const TenantHierarchyView: React.FC<{ className="mr-2 flex-shrink-0 text-muted-foreground" /> -
- - {node.name} - - {isSeedTenant(node) && ( - +
+ - {t("ui.admin.tenants.seed_badge", "초기 설정")} - - )} + {node.name} + + {isSeedTenant(node) && ( + + {t("ui.admin.tenants.seed_badge", "초기 설정")} + + )} +
+ {(() => { + const parentPath = tenantParentPathMap.get(node.id) ?? []; + return ( +

+ {parentPath.length > 0 + ? parentPath.join(" / ") + : t("ui.admin.tenants.path.root", "최상위")} +

+ ); + })()}
- {node.id} + + {abbreviateUuid(node.id)} + + + + {(() => { + const { primary, secondary } = splitTenantTypeLabel( + getTenantTypeLabel(node.type), + ); + return ( +
+ + {primary} + + {secondary ? ( + + {secondary} + + ) : null} +
+ ); + })()} +
+ + + {node.slug} + - - {node.type} + + {node.status === "active" + ? t("ui.common.status.active", "활성") + : t("ui.common.status.inactive", "비활성")} - {node.slug} - -
- - statusMutation.mutate({ - tenantId: node.id, - status: checked ? "active" : "inactive", - }) - } - disabled={ - statusMutation.isPending || - node.id === profile?.tenantId || - isSeedTenant(node) - } - aria-label={t( - "ui.admin.tenants.toggle_status", - "{{name}} 활성 상태", - { name: node.name }, - )} - /> - - {t(`ui.common.status.${node.status}`, node.status)} + +
+ + {t("ui.admin.tenants.table.members_count", "{{count}}명", { + count: node.recursiveMemberCount, + })} + + + {t("ui.admin.tenants.table.members_recursive", "하위 포함")}
- - {node.recursiveMemberCount} - - - {node.updatedAt - ? new Date(node.updatedAt).toLocaleString("ko-KR") - : "-"} + + {node.updatedAt ? ( +
+ + {new Date(node.updatedAt).toLocaleDateString("ko-KR")} + + + {new Date(node.updatedAt).toLocaleTimeString("ko-KR")} + +
+ ) : ( + - + )}
); }; return ( -
+
- +
- - - 0 && - visibleSelectedCount === deletableTenants.length - } - onCheckedChange={(checked) => onSelectAll(!!checked)} - /> + + +
+ 0 && + visibleSelectedCount === deletableTenants.length + } + onCheckedChange={(checked) => onSelectAll(!!checked)} + /> +
requestSort("name")} > -
+
{t("ui.admin.tenants.table.name", "NAME")} {getSortIcon("name")}
requestSort("id")} > -
+
{t("ui.admin.tenants.table.id", "ID")} {getSortIcon("id")}
requestSort("type")} > -
+
{t("ui.admin.tenants.table.type", "TYPE")} {getSortIcon("type")}
requestSort("slug")} > -
+
{t("ui.admin.tenants.table.slug", "SLUG")} {getSortIcon("slug")}
requestSort("status")} > -
+
{t("ui.admin.tenants.table.status", "STATUS")} {getSortIcon("status")}
requestSort("recursiveMemberCount")} > -
+
{t("ui.admin.tenants.table.members", "MEMBERS")} {getSortIcon("recursiveMemberCount")}
requestSort("updatedAt")} > -
+
{t("ui.admin.tenants.table.updated", "UPDATED")} {getSortIcon("updatedAt")}
- - {rowVirtualizer.getTotalSize() > 0 && - virtualRows.length > 0 && - !(isTest && flattenedRows.length < 100) && ( -
- - )} - + {flattenedRows.length === 0 && !isLoading && ( - + - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} + {emptyMessage} )} - {isTest && flattenedRows.length < 100 + {!shouldVirtualizeRows ? flattenedRows.map((row, index) => renderRow(row, index)) : virtualRows.map((virtualRow) => renderRow( @@ -1927,20 +2087,14 @@ const TenantHierarchyView: React.FC<{ ), )} - {rowVirtualizer.getTotalSize() > 0 && - virtualRows.length > 0 && - !(isTest && flattenedRows.length < 100) && ( - - - )} - {isFetchingNextPage && ( - +
diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index ec64d2f7..4975d072 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -127,7 +127,7 @@ describe("UserListPage search rendering", () => { renderUserListPage(); await screen.findByText("User 0"); - const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); + const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색"); const renderCountBeforeTyping = selectRenderCounter.count; fireEvent.change(searchInput, { target: { value: "u" } }); @@ -179,7 +179,7 @@ describe("UserListPage search rendering", () => { renderUserListPage(); await screen.findByText("User 0"); - const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); + const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색"); const startedAt = performance.now(); fireEvent.change(searchInput, { target: { value: "user 19" } }); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 3a10e02a..63d22d17 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -204,14 +204,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({ -
+
{user.name} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index ffb10bb1..c4915449 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,10 +1268,21 @@ 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" +members_count = "{{count}} members" members = "Members" +members_recursive = "Includes descendants" name = "NAME" slug = "SLUG" status = "STATUS" @@ -1389,7 +1401,7 @@ change_status = "Change {{name}} status" empty = "No users found." fetch_error = "Failed to fetch user list." search_label = "Search Users" -search_placeholder = "Search by name or email..." +search_placeholder = "Search by name or email" subtitle = "View and manage system users." toggle_status = "{{name}} active status" title = "User Management" @@ -1424,7 +1436,7 @@ remove_success = "Successfully excluded from organization." [ui.admin.tenants.list] search_label = "Search Tenants" -search_placeholder = "Search by name or slug..." +search_placeholder = "Search by name, slug, or ID" title = "Tenant List" [ui.admin.users.list.breadcrumb] @@ -1442,12 +1454,18 @@ count = "Registered users" title = "User Registry" [ui.admin.users.list.table] -actions = "ACTIONS" -created = "CREATED" -name_email = "NAME / EMAIL" -role = "ROLE" -status = "STATUS" -tenant_dept = "TENANT / DEPT" +actions = "Actions" +created = "Created" +email = "Email" +id = "ID" +name = "Name" +phone = "Phone" +role = "Role" +status = "Status" +tenant_dept = "Tenant / Dept" + +[ui.admin.users] +data_mgmt = "Data Management" [ui.admin.users.table] email = "Email" @@ -1531,6 +1549,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 03f6502b..1e788dfb 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,26 @@ 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_count = "{{count}}명" members = "멤버수" -name = "NAME" -slug = "SLUG" -status = "STATUS" +members_recursive = "하위 포함" +name = "이름" +slug = "슬러그" +status = "상태" type = "유형" -updated = "UPDATED" +updated = "수정일" [ui.admin.users] csv_template = "템플릿 다운로드" @@ -1392,7 +1404,7 @@ change_status = "{{name}} 상태 변경" empty = "검색 결과가 없습니다." fetch_error = "사용자 목록 조회에 실패했습니다." search_label = "사용자 검색" -search_placeholder = "이름 또는 이메일 검색..." +search_placeholder = "이름 또는 이메일 검색" subtitle = "시스템 사용자를 조회하고 관리합니다." toggle_status = "{{name}} 활성 상태" title = "사용자 관리" @@ -1427,7 +1439,7 @@ remove_success = "조직에서 제외되었습니다." [ui.admin.tenants.list] search_label = "테넌트 검색" -search_placeholder = "테넌트 이름 또는 슬러그 검색..." +search_placeholder = "이름 또는 슬러그, ID 검색" title = "테넌트 목록" [ui.admin.users.list.breadcrumb] @@ -1445,12 +1457,18 @@ count = "총 {{count}}명의 사용자가 등록되어 있습니다." title = "사용자 레지스트리" [ui.admin.users.list.table] -actions = "ACTIONS" -created = "CREATED" -name_email = "NAME / EMAIL" -role = "ROLE" -status = "STATUS" -tenant_dept = "TENANT / DEPT" +actions = "액션" +created = "등록일" +email = "이메일" +id = "ID" +name = "이름" +phone = "전화번호" +role = "역할" +status = "상태" +tenant_dept = "테넌트 / 부서" + +[ui.admin.users] +data_mgmt = "데이터 관리" [ui.admin.users.table] email = "이메일" @@ -1534,6 +1552,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..4808dab7 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1291,6 +1291,8 @@ slug = "" status = "" [ui.admin.tenants.table] +members_count = "" +members_recursive = "" actions = "" id = "" members = "" @@ -1426,11 +1428,17 @@ title = "" [ui.admin.users.list.table] actions = "" created = "" -name_email = "" +email = "" +id = "" +name = "" +phone = "" role = "" status = "" tenant_dept = "" +[ui.admin.users] +data_mgmt = "" + [ui.admin.users.table] email = "" name = "" @@ -1513,6 +1521,10 @@ unknown_name = "" logout = "" profile = "" +[ui.shell.sidebar] +collapse = "" +expand = "" + [ui.shell.role] rp_admin = "" super_admin = "" diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 632ef2f0..30222f8e 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -126,7 +126,7 @@ test.describe("Authentication", () => { await page.goto("/"); await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute( "href", - "http://localhost:5175/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue", + /\/login\?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue$/, ); }); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 71bbe54f..054398e8 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -107,9 +107,11 @@ test.describe("Tenants Management", () => { await expect(page.locator("table")).toContainText("Tenant A", { timeout: 10000, }); - await expect(page.locator("table")).toContainText(internalTenantId); + await expect( + page.getByTestId(`tenant-internal-id-${internalTenantId}`), + ).toHaveText("c5839444-2de0-4a37-99b0-..."); await expect(page.locator("table")).toContainText("COMPANY"); - await expect(page.locator("table")).not.toContainText("일반 기업"); + await expect(page.locator("table")).toContainText("일반 기업"); const headerWhiteSpace = await page .locator("table thead th") @@ -188,16 +190,14 @@ test.describe("Tenants Management", () => { await page.goto("/tenants"); await page - .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) + .getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i) .fill("team-1"); await expect(page.locator("table")).toContainText("Platform"); + await page.getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i).fill(""); await page - .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) - .fill(""); - await page - .locator("tbody tr") - .filter({ hasText: "Planning" }) + .getByTestId("tenant-internal-id-dept-1") + .locator("xpath=ancestor::tr") .getByRole("checkbox") .click(); @@ -291,8 +291,8 @@ test.describe("Tenants Management", () => { await page.getByPlaceholder(/UUID|슬러그|slug/i).fill(""); await page.keyboard.press("Enter"); await page - .locator("tbody tr") - .filter({ hasText: "Acme" }) + .getByTestId("tenant-internal-id-company-1") + .locator("xpath=ancestor::tr") .getByRole("checkbox") .click(); @@ -363,7 +363,7 @@ test.describe("Tenants Management", () => { await page.goto("/tenants"); await expect( - page.getByText("총 501개의 테넌트가 등록되어 있습니다."), + page.getByText("총 500개의 테넌트가 등록되어 있습니다."), ).toBeVisible(); await expect(page.getByRole("button", { name: "더 불러오기" })).toHaveCount( 0, diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index b54ab46b..6e86b030 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -602,11 +602,11 @@ test.describe("User Management", () => { await expect(page.getByText("Load User 0")).toBeVisible(); const initialMs = performance.now() - initialStartedAt; - const searchInput = page.getByPlaceholder("이름 또는 이메일 검색..."); + const searchInput = page.getByPlaceholder("이름 또는 이메일 검색"); await searchInput.fill("Load User 19999"); const searchMs = await page.evaluate(async () => { const input = Array.from(document.querySelectorAll("input")).find( - (candidate) => candidate.placeholder === "이름 또는 이메일 검색...", + (candidate) => candidate.placeholder === "이름 또는 이메일 검색", ); if (!input) { diff --git a/common/core/components/audit/AuditLogTable.tsx b/common/core/components/audit/AuditLogTable.tsx index 30d94c0c..42c4371e 100644 --- a/common/core/components/audit/AuditLogTable.tsx +++ b/common/core/components/audit/AuditLogTable.tsx @@ -1,7 +1,9 @@ import { ChevronDown, ChevronUp, Copy } from "lucide-react"; import * as React from "react"; -import { getCommonBadgeClasses } from "../../../ui/badge"; -import type { CommonBadgeVariant } from "../../../ui/badge"; +import { + getCommonBadgeClasses, + type CommonBadgeVariant, +} from "../../../ui/badge"; import { getCommonButtonClasses } from "../../../ui/button"; import { commonStickyTableHeaderClass, 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 ( -
-
-