1
0
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:
2026-06-05 21:26:52 +09:00
25 changed files with 890 additions and 391 deletions

View File

@@ -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();

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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" } });

View File

@@ -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}

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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 = ""

View File

@@ -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$/,
);
});

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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:

View File

@@ -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();

View File

@@ -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}>

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -1417,6 +1417,10 @@ unknown_name = ""
logout = ""
profile = ""
[ui.shell.sidebar]
collapse = ""
expand = ""
[ui.shell.role]
rp_admin = ""
super_admin = ""

View File

@@ -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

View File

@@ -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"

View File

@@ -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 = "계정"

View File

@@ -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 = ""

View File

@@ -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