forked from baron/baron-sso
Merge pull request 'feature/af-tenant-ui' (#1024) from feature/af-tenant-ui into dev
Reviewed-on: baron/baron-sso#1024
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{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}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{label}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@@ -561,10 +586,17 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -578,13 +610,23 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
@@ -785,7 +827,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
<Outlet context={isSidebarCollapsed} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const names: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
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={
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative mr-2 w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름, 슬러그, UUID 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"이름 또는 슬러그, ID 검색",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
aria-pressed={viewMode === "tree"}
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
aria-pressed={viewMode === "table"}
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="tenant-data-mgmt-btn"
|
||||
className="gap-2"
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
aria-pressed={viewMode === "tree"}
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-menu-item"
|
||||
className="cursor-pointer"
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
aria-pressed={viewMode === "table"}
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.csv_template", "템플릿 다운로드")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_without_ids",
|
||||
"UUID 제외 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_with_ids",
|
||||
"UUID 포함 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RoleGuard>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
className="w-9 px-0"
|
||||
title={t("ui.common.refresh", "새로고침")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span className="sr-only">
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button asChild>
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="tenant-data-mgmt-btn"
|
||||
className="gap-2 h-9"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileSpreadsheet
|
||||
size={16}
|
||||
className="mr-2 opacity-50"
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.tenants.csv_template",
|
||||
"템플릿 다운로드",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_without_ids",
|
||||
"UUID 제외 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_with_ids",
|
||||
"UUID 포함 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RoleGuard>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
className="h-9 w-9 px-0"
|
||||
title={t("ui.common.refresh", "새로고침")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span className="sr-only">
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{importMessage ? (
|
||||
<div
|
||||
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm"
|
||||
@@ -884,7 +945,7 @@ function TenantListPage() {
|
||||
{importMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -900,7 +961,9 @@ function TenantListPage() {
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개의 테넌트가 등록되어 있습니다.",
|
||||
{
|
||||
count: scopeTenantId ? scopedTenants.length : tenantTotal,
|
||||
count: scopeTenantId
|
||||
? scopedTenants.length
|
||||
: allTenants.length,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
@@ -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<TenantSortKey> | 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<HTMLDivElement>(null);
|
||||
const isSidebarCollapsed = useOutletContext<boolean>() ?? 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<Set<string>>(() => {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
>
|
||||
<TableCell className="text-center px-4">
|
||||
@@ -1742,182 +1835,249 @@ const TenantHierarchyView: React.FC<{
|
||||
className="mr-2 flex-shrink-0 text-muted-foreground"
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${node.id}`}
|
||||
className="cursor-pointer truncate text-primary hover:underline"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
{isSeedTenant(node) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 text-[10px]"
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${node.id}`}
|
||||
className="block max-w-full truncate text-foreground transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
{node.name}
|
||||
</Link>
|
||||
{isSeedTenant(node) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 text-[10px]"
|
||||
>
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const parentPath = tenantParentPathMap.get(node.id) ?? [];
|
||||
return (
|
||||
<p className="mt-0.5 truncate text-xs font-normal text-muted-foreground">
|
||||
{parentPath.length > 0
|
||||
? parentPath.join(" / ")
|
||||
: t("ui.admin.tenants.path.root", "최상위")}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
className="whitespace-nowrap overflow-hidden pl-5"
|
||||
data-testid={`tenant-internal-id-${node.id}`}
|
||||
>
|
||||
{node.id}
|
||||
<code className="inline-block max-w-full overflow-hidden rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground text-ellipsis">
|
||||
{abbreviateUuid(node.id)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap overflow-visible">
|
||||
{(() => {
|
||||
const { primary, secondary } = splitTenantTypeLabel(
|
||||
getTenantTypeLabel(node.type),
|
||||
);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col leading-tight">
|
||||
<span
|
||||
className={cn(
|
||||
"block max-w-full text-xs font-medium uppercase tracking-[0.04em]",
|
||||
getTenantTypeTextClass(node.type),
|
||||
)}
|
||||
>
|
||||
{primary}
|
||||
</span>
|
||||
{secondary ? (
|
||||
<span className="mt-0.5 block max-w-none whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap overflow-hidden">
|
||||
<code className="inline-flex max-w-full items-center overflow-hidden rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground text-ellipsis">
|
||||
{node.slug}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{node.type}
|
||||
<Badge
|
||||
variant={node.status === "active" ? "default" : "muted"}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs uppercase",
|
||||
node.status === "active"
|
||||
? "border-transparent bg-blue-500 text-white hover:bg-blue-500/90 hover:text-white"
|
||||
: "border-border bg-secondary/60 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{node.status === "active"
|
||||
? t("ui.common.status.active", "활성")
|
||||
: t("ui.common.status.inactive", "비활성")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.slug}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={node.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
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 },
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${node.status}`, node.status)}
|
||||
<TableCell className="whitespace-nowrap pl-3">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">
|
||||
{t("ui.admin.tenants.table.members_count", "{{count}}명", {
|
||||
count: node.recursiveMemberCount,
|
||||
})}
|
||||
</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("ui.admin.tenants.table.members_recursive", "하위 포함")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{node.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{node.updatedAt
|
||||
? new Date(node.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
<TableCell className="whitespace-nowrap text-right pl-1">
|
||||
{node.updatedAt ? (
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span className="text-xs">
|
||||
{new Date(node.updatedAt).toLocaleDateString("ko-KR")}
|
||||
</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
{new Date(node.updatedAt).toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="custom-scrollbar relative flex-1 overflow-auto"
|
||||
data-testid="tenant-table-container"
|
||||
>
|
||||
<Table className="relative min-w-[1180px] border-separate border-spacing-0">
|
||||
<Table
|
||||
className="relative border-separate border-spacing-0"
|
||||
style={{ display: "grid", minWidth: tenantTableMinWidth }}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableHead
|
||||
className={`${tenantTableHeadClassName} w-[48px] text-center`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[280px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} min-w-[500px]`}
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} min-w-[220px] pl-5`}
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||
onClick={() => requestSort("slug")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
{getSortIcon("slug")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={tenantTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{getSortIcon("recursiveMemberCount")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={tenantTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`${tenantTableHeadContentClassName} justify-end`}
|
||||
>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{getSortIcon("updatedAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="relative">
|
||||
{rowVirtualizer.getTotalSize() > 0 &&
|
||||
virtualRows.length > 0 &&
|
||||
!(isTest && flattenedRows.length < 100) && (
|
||||
<tr style={{ height: `${virtualRows[0].start}px` }}>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<TableBody
|
||||
className="relative"
|
||||
style={
|
||||
shouldVirtualizeRows
|
||||
? {
|
||||
display: "grid",
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minWidth: tenantTableMinWidth,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flattenedRows.length === 0 && !isLoading && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{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) && (
|
||||
<tr
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end}px`,
|
||||
}}
|
||||
>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell colSpan={8} className="py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -204,14 +204,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="user-list-search"
|
||||
name="user-list-search"
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
"이름 또는 이메일 검색",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={localSearch}
|
||||
@@ -1005,7 +1005,7 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
className="block max-w-[150px] truncate font-medium text-foreground transition-colors hover:text-primary hover:underline"
|
||||
title={user.name}
|
||||
>
|
||||
{user.name}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<aside
|
||||
className={
|
||||
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</div>
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.brandSectionCollapsed
|
||||
: shellLayoutClasses.brandSection
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.brandWrapCollapsed
|
||||
: shellLayoutClasses.brandWrap
|
||||
}
|
||||
>
|
||||
{onToggleCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className="grid h-11 w-11 place-items-center rounded-xl border border-border bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)] transition hover:bg-primary/20"
|
||||
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||
title={collapsed ? expandLabel : collapseLabel}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{collapsed ? expandLabel : collapseLabel}
|
||||
</span>
|
||||
{collapsed ? <Menu size={20} /> : <SquareMenu size={20} />}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.brandIconCollapsed
|
||||
: shellLayoutClasses.brandIcon
|
||||
}
|
||||
>
|
||||
{brandIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className={collapsed ? "hidden" : "block"}>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{brandLabel}
|
||||
</p>
|
||||
@@ -40,7 +88,15 @@ export function AppSidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav>
|
||||
<nav
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.navWrapCollapsed
|
||||
: shellLayoutClasses.navWrap
|
||||
}
|
||||
>
|
||||
{navContent}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>{footerContent}</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ type ShellProfileSummaryParams = {
|
||||
|
||||
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
|
||||
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY;
|
||||
export const SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY =
|
||||
"baron_shell_sidebar_collapsed";
|
||||
export type { ShellSidebarNavItem } from "./AppSidebar";
|
||||
export { AppSidebar } from "./AppSidebar";
|
||||
export { shellLayoutClasses } from "./layout";
|
||||
@@ -52,6 +54,25 @@ export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
|
||||
writeSessionExpiryEnabled(isEnabled);
|
||||
}
|
||||
|
||||
export function readShellSidebarCollapsed(defaultCollapsed = false) {
|
||||
const stored = window.localStorage.getItem(
|
||||
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (stored === null) {
|
||||
return defaultCollapsed;
|
||||
}
|
||||
|
||||
return stored === "true";
|
||||
}
|
||||
|
||||
export function writeShellSidebarCollapsed(isCollapsed: boolean) {
|
||||
window.localStorage.setItem(
|
||||
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
String(isCollapsed),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildShellProfileSummary({
|
||||
profileName,
|
||||
profileEmail,
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
export const shellLayoutClasses = {
|
||||
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
|
||||
rootCollapsed:
|
||||
"grid min-h-screen grid-cols-[80px,minmax(0,1fr)] bg-background text-foreground",
|
||||
aside:
|
||||
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||
asideCollapsed:
|
||||
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||
asideStatic:
|
||||
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
|
||||
brandSection:
|
||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||
brandSectionCollapsed:
|
||||
"flex items-center justify-between px-3 py-4 md:block md:space-y-4 md:px-2 md:py-6",
|
||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||
brandWrapCollapsed: "flex items-center gap-3 md:flex-col md:items-center",
|
||||
brandIcon:
|
||||
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||
brandIconCollapsed:
|
||||
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||
scopeBadge:
|
||||
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
||||
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
||||
navWrapCollapsed: "px-2 pb-4 md:px-2 md:pb-8",
|
||||
navMeta:
|
||||
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
||||
navList: "flex flex-col gap-1",
|
||||
navItemBase:
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemBaseCollapsed:
|
||||
"flex items-center justify-center gap-0 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemActive:
|
||||
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
||||
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
@@ -24,6 +36,8 @@ export const shellLayoutClasses = {
|
||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||
logoutButton:
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
logoutButtonCollapsed:
|
||||
"flex w-full items-center justify-center gap-0 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
header:
|
||||
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerElevated:
|
||||
@@ -31,8 +45,11 @@ export const shellLayoutClasses = {
|
||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||
headerTitleWrap: "flex flex-col gap-1",
|
||||
headerActions: "flex items-center gap-2 text-sm",
|
||||
headerActionsCollapsed: "flex items-center gap-2 text-sm",
|
||||
actionButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sidebarToggleButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sessionBadge:
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
profileInitial:
|
||||
|
||||
@@ -116,6 +116,24 @@ describe("devfront AppLayout", () => {
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="사이드바 접기"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
collapseButton.click();
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="사이드바 펼치기"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellSidebarCollapsed,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
writeShellSidebarCollapsed,
|
||||
} from "../../../../common/shell";
|
||||
import { fetchMe } from "../../features/auth/authApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -118,6 +120,9 @@ function AppLayout() {
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||
readShellSidebarCollapsed(false),
|
||||
);
|
||||
const [, setDevelopmentRenderRevision] = useState(0);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
@@ -352,26 +357,42 @@ function AppLayout() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleSidebarToggle = () => {
|
||||
setIsSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
|
||||
const label = t(labelKey, labelFallback);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
@@ -379,22 +400,39 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.dev.brand", "Baron Sign In")}
|
||||
brandTitle={t("ui.dev.console_title", "Developer Console")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.content}>
|
||||
|
||||
@@ -1361,6 +1361,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)"
|
||||
|
||||
@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
|
||||
logout = "Logout"
|
||||
profile = "내 정보"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "사이드바 접기"
|
||||
expand = "사이드바 펼치기"
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = "서비스 관리자 (RP Admin)"
|
||||
super_admin = "시스템 관리자 (Super Admin)"
|
||||
|
||||
@@ -1417,6 +1417,10 @@ unknown_name = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = ""
|
||||
expand = ""
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
|
||||
@@ -55,6 +55,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./adminfront/Dockerfile
|
||||
args:
|
||||
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: adminfront
|
||||
container_name: baron_adminfront
|
||||
env_file:
|
||||
- .env
|
||||
@@ -80,6 +84,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./devfront/Dockerfile
|
||||
args:
|
||||
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: devfront
|
||||
container_name: baron_devfront
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -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,11 +1166,13 @@ 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"
|
||||
import = "Import"
|
||||
seed_badge = "Seed"
|
||||
path.root = "Root"
|
||||
title = "Tenant Registry"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
|
||||
@@ -1441,9 +1452,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"
|
||||
@@ -2586,6 +2602,10 @@ title_remote = "Sign-in Approved"
|
||||
logout = "Logout"
|
||||
profile = "My Profile"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "Collapse sidebar"
|
||||
expand = "Expand sidebar"
|
||||
|
||||
[ui.shell.profile]
|
||||
menu_aria = "Open account menu"
|
||||
menu_title = "Account"
|
||||
|
||||
@@ -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,8 +1661,10 @@ user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
data_mgmt = "데이터 관리"
|
||||
delete_selected = "선택 삭제"
|
||||
seed_badge = "초기 설정"
|
||||
path.root = "최상위"
|
||||
title = "테넌트 목록"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
|
||||
@@ -1904,13 +1915,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"
|
||||
|
||||
@@ -3011,6 +3027,10 @@ title_remote = "로그인 승인 완료"
|
||||
logout = "로그아웃"
|
||||
profile = "내 정보"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "사이드바 접기"
|
||||
expand = "사이드바 펼치기"
|
||||
|
||||
[ui.shell.profile]
|
||||
menu_aria = "계정 메뉴 열기"
|
||||
menu_title = "계정"
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -1514,6 +1523,7 @@ user = ""
|
||||
add = ""
|
||||
delete_selected = ""
|
||||
seed_badge = ""
|
||||
path.root = ""
|
||||
title = ""
|
||||
view_org_chart = ""
|
||||
|
||||
@@ -1781,9 +1791,14 @@ status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = ""
|
||||
context = ""
|
||||
id = ""
|
||||
id_copy = ""
|
||||
members = ""
|
||||
members_count = ""
|
||||
members_recursive = ""
|
||||
name = ""
|
||||
root = ""
|
||||
slug = ""
|
||||
status = ""
|
||||
type = ""
|
||||
@@ -2890,6 +2905,10 @@ title_remote = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = ""
|
||||
expand = ""
|
||||
|
||||
[ui.shell.profile]
|
||||
menu_aria = ""
|
||||
menu_title = ""
|
||||
|
||||
@@ -128,8 +128,8 @@ run_with_retry() {
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
playwright_install_cmd=(pnpm exec playwright install)
|
||||
playwright_install_desc="pnpm exec playwright install"
|
||||
playwright_project_args=()
|
||||
|
||||
has_webkit_host_dependencies() {
|
||||
@@ -179,21 +179,21 @@ has_webkit_host_dependencies() {
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
playwright_install_cmd=(pnpm exec playwright install --with-deps)
|
||||
playwright_install_desc="pnpm exec playwright install --with-deps"
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
playwright_install_cmd=(pnpm exec playwright install --with-deps)
|
||||
playwright_install_desc="pnpm exec playwright install --with-deps"
|
||||
elif ! has_webkit_host_dependencies; then
|
||||
playwright_install_cmd=(npx playwright install chromium firefox)
|
||||
playwright_install_desc="npx playwright install chromium firefox"
|
||||
playwright_install_cmd=(pnpm exec playwright install chromium firefox)
|
||||
playwright_install_desc="pnpm exec playwright install chromium firefox"
|
||||
playwright_project_args=(--project=chromium --project=firefox)
|
||||
{
|
||||
echo "# Adminfront WebKit Skipped"
|
||||
echo
|
||||
echo "- Reason: WebKit host dependencies are not installed and this user cannot run passwordless sudo."
|
||||
echo "- Action: Running Chromium and Firefox projects only."
|
||||
echo "- To enable WebKit locally: run \`cd adminfront && npx playwright install-deps webkit\` with sudo privileges."
|
||||
echo "- To enable WebKit locally: run \`cd adminfront && pnpm exec playwright install-deps webkit\` with sudo privileges."
|
||||
} > reports/adminfront-webkit-skipped.md
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user