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(); 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 () => { it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
renderLayout(); renderLayout();

View File

@@ -26,11 +26,13 @@ import {
buildShellProfileSummary, buildShellProfileSummary,
buildShellSessionStatus, buildShellSessionStatus,
readShellSessionExpiryEnabled, readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme, readShellTheme,
type ShellSidebarNavItem, type ShellSidebarNavItem,
type ShellTranslator, type ShellTranslator,
shellLayoutClasses, shellLayoutClasses,
writeShellSessionExpiryEnabled, writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell"; } from "../../../../common/shell";
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess"; import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
@@ -165,6 +167,9 @@ function AppLayout() {
const isDevelopmentRuntime = import.meta.env.MODE === "development"; const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime), readShellSessionExpiryEnabled(!isDevelopmentRuntime),
); );
@@ -508,10 +513,18 @@ function AppLayout() {
return next; return next;
}); });
}; };
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = ( const sidebarNavContent = (
<div className={shellLayoutClasses.navList}> <div className={shellLayoutClasses.navList}>
{navItems.map((item) => { {navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item; const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
const label = t(labelKey, labelFallback);
if (isExternal) { if (isExternal) {
return ( return (
@@ -522,11 +535,18 @@ function AppLayout() {
rel="noopener noreferrer" rel="noopener noreferrer"
className={[ className={[
shellLayoutClasses.navItemBase, shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
shellLayoutClasses.navItemIdle, shellLayoutClasses.navItemIdle,
].join(" ")} ].join(" ")}
title={label}
aria-label={label}
> >
<Icon size={18} /> <Icon size={18} />
<span>{t(labelKey, labelFallback)}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>
{label}
</span>
</a> </a>
); );
} }
@@ -539,6 +559,9 @@ function AppLayout() {
className={({ isActive }) => className={({ isActive }) =>
[ [
shellLayoutClasses.navItemBase, shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
item.isActive !== undefined item.isActive !== undefined
? item.isActive ? item.isActive
? shellLayoutClasses.navItemActive ? shellLayoutClasses.navItemActive
@@ -548,9 +571,11 @@ function AppLayout() {
: shellLayoutClasses.navItemIdle, : shellLayoutClasses.navItemIdle,
].join(" ") ].join(" ")
} }
title={label}
aria-label={label}
> >
<Icon size={18} /> <Icon size={18} />
<span>{t(labelKey, labelFallback)}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink> </NavLink>
); );
})} })}
@@ -561,10 +586,17 @@ function AppLayout() {
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className={shellLayoutClasses.logoutButton} className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
> >
<LogOut size={18} /> <LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button> </button>
</div> </div>
); );
@@ -578,13 +610,23 @@ function AppLayout() {
} }
return ( return (
<div className={shellLayoutClasses.root}> <div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar <AppSidebar
brandLabel={t("ui.admin.brand", "Baron 로그인")} brandLabel={t("ui.admin.brand", "Baron 로그인")}
brandTitle={t("ui.admin.title", "Admin Control")} brandTitle={t("ui.admin.title", "Admin Control")}
brandIcon={<ShieldHalf size={20} />} brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent} navContent={sidebarNavContent}
footerContent={sidebarFooterContent} footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/> />
<div className={shellLayoutClasses.contentWide}> <div className={shellLayoutClasses.contentWide}>
@@ -785,7 +827,7 @@ function AppLayout() {
</div> </div>
</header> </header>
<main className={shellLayoutClasses.mainMinWidth}> <main className={shellLayoutClasses.mainMinWidth}>
<Outlet /> <Outlet context={isSidebarCollapsed} />
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,4 @@
import { import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
type UseMutationResult,
useInfiniteQuery,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
@@ -25,7 +20,7 @@ import {
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import * as React from "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 { PageHeader } from "../../../../../common/core/components/page";
import { import {
type SortConfig, type SortConfig,
@@ -33,6 +28,7 @@ import {
sortItems, sortItems,
toggleSort, toggleSort,
} from "../../../../../common/core/utils"; } from "../../../../../common/core/utils";
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { RoleGuard } from "../../../components/auth/RoleGuard"; import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
@@ -68,7 +64,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "../../../components/ui/select"; } from "../../../components/ui/select";
import { Switch } from "../../../components/ui/switch";
import { import {
Table, Table,
TableBody, TableBody,
@@ -79,7 +74,6 @@ import {
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import type { UserProfileResponse } from "../../../lib/adminApi";
import { import {
deleteTenantsBulk, deleteTenantsBulk,
exportTenantsCSV, exportTenantsCSV,
@@ -124,6 +118,10 @@ const tenantCSVTemplate =
const tenantPageSize = 500; const tenantPageSize = 500;
const _tenantVirtualizationThreshold = 250; const _tenantVirtualizationThreshold = 250;
const _tenantEstimatedRowHeight = 73; 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 _tenantLoadAheadPx = 360;
const _tenantLoadAheadRows = 30; 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__"; const noImportParentRef = "__none__";
function tenantParentRef(tenantId: string) { 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({ const bulkUpdateStatusMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
tenantIds, tenantIds,
@@ -450,7 +499,6 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null; : null;
const tenantTotal = query.data?.pages[0]?.total ?? 0;
const hanmacFamilyTenantId = React.useMemo(() => { const hanmacFamilyTenantId = React.useMemo(() => {
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
if (typeof envTenantId === "string" && envTenantId.trim()) { if (typeof envTenantId === "string" && envTenantId.trim()) {
@@ -708,174 +756,187 @@ function TenantListPage() {
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
)} )}
actions={ actions={
<> <div className="min-w-0 space-y-2">
<div className="flex items-center gap-2"> <SearchFilterBar
<div className="relative mr-2 w-64"> primary={
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <>
<Input <div className="relative w-56">
placeholder={t( <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
"ui.admin.tenants.list.search_placeholder", <Input
"테넌트 이름, 슬러그, UUID 검색...", placeholder={t(
)} "ui.admin.tenants.list.search_placeholder",
className="h-9 pl-9" "이름 또는 슬러그, ID 검색",
value={search} )}
onChange={(e) => setSearch(e.target.value)} className="h-9 pl-9"
onKeyDown={(e) => { value={search}
if (e.key === "Enter") { onChange={(e) => setSearch(e.target.value)}
query.refetch(); onKeyDown={(e) => {
} if (e.key === "Enter") {
}} query.refetch();
/> }
</div> }}
/>
</div>
<div <div
className="flex rounded-md border bg-background p-0.5" className="flex rounded-md border bg-background p-0.5"
data-testid="tenant-view-mode-toggle" 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>
<Button <Button
variant="outline" type="button"
data-testid="tenant-data-mgmt-btn" variant={viewMode === "tree" ? "default" : "ghost"}
className="gap-2" size="sm"
className="h-9 gap-1.5"
aria-pressed={viewMode === "tree"}
onClick={() => setViewMode("tree")}
data-testid="tenant-view-tree-btn"
> >
<LayoutDashboard size={16} /> <Network size={14} />
{t("ui.admin.tenants.data_mgmt", "데이터 관리")} {t("ui.admin.tenants.view.tree", "리")}
<ChevronDown size={14} className="opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> <Button
<DropdownMenuContent align="end" className="w-56"> type="button"
<DropdownMenuItem variant={viewMode === "table" ? "default" : "ghost"}
onClick={handleTemplateDownload} size="sm"
data-testid="tenant-template-menu-item" className="h-9 gap-1.5"
className="cursor-pointer" aria-pressed={viewMode === "table"}
onClick={() => setViewMode("table")}
data-testid="tenant-view-table-btn"
> >
<FileSpreadsheet size={16} className="mr-2 opacity-50" /> <List size={14} />
{t("ui.admin.tenants.csv_template", "템플릿 다운로드")} {t("ui.admin.tenants.view.table", "평면")}
</DropdownMenuItem> </Button>
<DropdownMenuSeparator /> </div>
<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 <Button
variant="outline" type="button"
onClick={() => query.refetch()} variant={scopeTenantId ? "default" : "outline"}
disabled={query.isFetching} size="sm"
className="w-9 px-0" className="h-9 gap-2"
title={t("ui.common.refresh", "새로고침")} onClick={() => setScopePickerOpen(true)}
> data-testid="tenant-scope-picker-btn"
<RefreshCw size={16} /> >
<span className="sr-only"> <Network size={16} />
{t("ui.common.refresh", "새로고침")} {selectedScopeTenant
</span> ? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
</Button> name: selectedScopeTenant.name,
<RoleGuard roles={["super_admin"]}> })
<Button asChild> : t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
<Link to="/tenants/new"> </Button>
<Plus size={16} /> {scopeTenantId ? (
{t("ui.admin.tenants.add", "테넌트 추가")} <Button
</Link> type="button"
</Button> variant="ghost"
</RoleGuard> size="sm"
</div> 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 ? ( {importMessage ? (
<div <div
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm" className="rounded-md border border-border bg-secondary px-3 py-2 text-sm"
@@ -884,7 +945,7 @@ function TenantListPage() {
{importMessage} {importMessage}
</div> </div>
) : null} ) : null}
</> </div>
} }
/> />
@@ -900,7 +961,9 @@ function TenantListPage() {
"msg.admin.tenants.registry.count", "msg.admin.tenants.registry.count",
"총 {{count}}개의 테넌트가 등록되어 있습니다.", "총 {{count}}개의 테넌트가 등록되어 있습니다.",
{ {
count: scopeTenantId ? scopedTenants.length : tenantTotal, count: scopeTenantId
? scopedTenants.length
: allTenants.length,
}, },
)} )}
</CardDescription> </CardDescription>
@@ -921,8 +984,6 @@ function TenantListPage() {
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
search={search} search={search}
deletableTenants={deletableTenants} deletableTenants={deletableTenants}
statusMutation={statusMutation}
profile={profile}
sortConfig={sortConfig} sortConfig={sortConfig}
requestSort={requestSort} requestSort={requestSort}
getSortIcon={getSortIcon} getSortIcon={getSortIcon}
@@ -1499,13 +1560,6 @@ const TenantHierarchyView: React.FC<{
onSelectAll: (checked: boolean) => void; onSelectAll: (checked: boolean) => void;
search: string; search: string;
deletableTenants: TenantSummary[]; deletableTenants: TenantSummary[];
statusMutation: UseMutationResult<
TenantSummary,
Error,
{ tenantId: string; status: string },
unknown
>;
profile: UserProfileResponse | undefined;
sortConfig: SortConfig<TenantSortKey> | null; sortConfig: SortConfig<TenantSortKey> | null;
requestSort: (key: TenantSortKey) => void; requestSort: (key: TenantSortKey) => void;
getSortIcon: (key: TenantSortKey) => React.ReactNode; getSortIcon: (key: TenantSortKey) => React.ReactNode;
@@ -1522,8 +1576,6 @@ const TenantHierarchyView: React.FC<{
onSelectAll, onSelectAll,
search, search,
deletableTenants, deletableTenants,
statusMutation,
profile,
sortConfig, sortConfig,
requestSort, requestSort,
getSortIcon, getSortIcon,
@@ -1535,15 +1587,28 @@ const TenantHierarchyView: React.FC<{
isLoading, isLoading,
}) => { }) => {
const parentRef = React.useRef<HTMLDivElement>(null); const parentRef = React.useRef<HTMLDivElement>(null);
const isSidebarCollapsed = useOutletContext<boolean>() ?? false;
const isTest = const isTest =
(typeof process !== "undefined" && process.env.NODE_ENV === "test") || (typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
(typeof window !== "undefined" && (typeof window !== "undefined" &&
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE); (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( const { subTree } = React.useMemo(
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search), () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
[scopeTenantId, tenants, search], [scopeTenantId, tenants, search],
); );
const tenantParentPathMap = React.useMemo(
() => buildTenantParentPathMap(tenants),
[tenants],
);
// Initial expanded state: everything open // Initial expanded state: everything open
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => { const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {
@@ -1639,6 +1704,7 @@ const TenantHierarchyView: React.FC<{
}); });
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems();
const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100);
React.useEffect(() => { React.useEffect(() => {
if (isTest) return; if (isTest) return;
@@ -1668,6 +1734,22 @@ const TenantHierarchyView: React.FC<{
const visibleSelectedCount = selectedIds.filter((id) => const visibleSelectedCount = selectedIds.filter((id) =>
visibleSelectableIds.has(id), visibleSelectableIds.has(id),
).length; ).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 = ( const renderRow = (
node: TenantViewRow, node: TenantViewRow,
@@ -1693,8 +1775,19 @@ const TenantHierarchyView: React.FC<{
)} )}
style={ style={
virtualRow 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"> <TableCell className="text-center px-4">
@@ -1742,182 +1835,249 @@ const TenantHierarchyView: React.FC<{
className="mr-2 flex-shrink-0 text-muted-foreground" className="mr-2 flex-shrink-0 text-muted-foreground"
/> />
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="min-w-0">
<Link <div className="flex min-w-0 flex-wrap items-center gap-2">
to={`/tenants/${node.id}`} <Link
className="cursor-pointer truncate text-primary hover:underline" to={`/tenants/${node.id}`}
> className="block max-w-full truncate text-foreground transition-colors hover:text-primary hover:underline"
{node.name}
</Link>
{isSeedTenant(node) && (
<Badge
variant="secondary"
className="flex-shrink-0 text-[10px]"
> >
{t("ui.admin.tenants.seed_badge", "초기 설정")} {node.name}
</Badge> </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>
</div> </div>
</TableCell> </TableCell>
<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}`} 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>
<TableCell className="whitespace-nowrap"> <TableCell className="whitespace-nowrap">
<Badge variant="outline" className="font-mono text-[10px]"> <Badge
{node.type} 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> </Badge>
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{node.slug}</TableCell> <TableCell className="whitespace-nowrap pl-3">
<TableCell className="whitespace-nowrap"> <div className="flex flex-col leading-tight">
<div className="flex items-center gap-2"> <span className="font-medium">
<Switch {t("ui.admin.tenants.table.members_count", "{{count}}명", {
checked={node.status === "active"} count: node.recursiveMemberCount,
onCheckedChange={(checked) => })}
statusMutation.mutate({ </span>
tenantId: node.id, <span className="mt-0.5 text-xs text-muted-foreground">
status: checked ? "active" : "inactive", {t("ui.admin.tenants.table.members_recursive", "하위 포함")}
})
}
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)}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="whitespace-nowrap text-right pl-1">
{node.recursiveMemberCount} {node.updatedAt ? (
</TableCell> <div className="flex flex-col items-end leading-tight">
<TableCell className="whitespace-nowrap text-xs"> <span className="text-xs">
{node.updatedAt {new Date(node.updatedAt).toLocaleDateString("ko-KR")}
? new Date(node.updatedAt).toLocaleString("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> </TableCell>
</TableRow> </TableRow>
); );
}; };
return ( 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 <div
ref={parentRef} ref={parentRef}
className="custom-scrollbar relative flex-1 overflow-auto" className="custom-scrollbar relative flex-1 overflow-auto"
data-testid="tenant-table-container" 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"> <TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow> <TableRow
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center"> style={{
<Checkbox display: "grid",
checked={ gridTemplateColumns: tenantTableGridTemplateColumns,
deletableTenants.length > 0 && minWidth: tenantTableMinWidth,
visibleSelectedCount === deletableTenants.length }}
} >
onCheckedChange={(checked) => onSelectAll(!!checked)} <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>
<TableHead <TableHead
className="min-w-[280px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50" className={`${tenantTableHeadInteractiveClassName} min-w-[500px]`}
onClick={() => requestSort("name")} onClick={() => requestSort("name")}
> >
<div className="flex items-center"> <div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.name", "NAME")} {t("ui.admin.tenants.table.name", "NAME")}
{getSortIcon("name")} {getSortIcon("name")}
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("id")}
> >
<div className="flex items-center"> <div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.id", "ID")} {t("ui.admin.tenants.table.id", "ID")}
{getSortIcon("id")} {getSortIcon("id")}
</div> </div>
</TableHead> </TableHead>
<TableHead <TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50" className={`${tenantTableHeadInteractiveClassName} pl-5`}
onClick={() => requestSort("type")} onClick={() => requestSort("type")}
> >
<div className="flex items-center"> <div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.type", "TYPE")} {t("ui.admin.tenants.table.type", "TYPE")}
{getSortIcon("type")} {getSortIcon("type")}
</div> </div>
</TableHead> </TableHead>
<TableHead <TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50" className={`${tenantTableHeadInteractiveClassName} pl-5`}
onClick={() => requestSort("slug")} onClick={() => requestSort("slug")}
> >
<div className="flex items-center"> <div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.slug", "SLUG")} {t("ui.admin.tenants.table.slug", "SLUG")}
{getSortIcon("slug")} {getSortIcon("slug")}
</div> </div>
</TableHead> </TableHead>
<TableHead <TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50" className={`${tenantTableHeadInteractiveClassName} pl-5`}
onClick={() => requestSort("status")} onClick={() => requestSort("status")}
> >
<div className="flex items-center"> <div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.status", "STATUS")} {t("ui.admin.tenants.table.status", "STATUS")}
{getSortIcon("status")} {getSortIcon("status")}
</div> </div>
</TableHead> </TableHead>
<TableHead <TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50" className={tenantTableHeadInteractiveClassName}
onClick={() => requestSort("recursiveMemberCount")} onClick={() => requestSort("recursiveMemberCount")}
> >
<div className="flex items-center"> <div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.members", "MEMBERS")} {t("ui.admin.tenants.table.members", "MEMBERS")}
{getSortIcon("recursiveMemberCount")} {getSortIcon("recursiveMemberCount")}
</div> </div>
</TableHead> </TableHead>
<TableHead <TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50" className={tenantTableHeadInteractiveClassName}
onClick={() => requestSort("updatedAt")} onClick={() => requestSort("updatedAt")}
> >
<div className="flex items-center"> <div
className={`${tenantTableHeadContentClassName} justify-end`}
>
{t("ui.admin.tenants.table.updated", "UPDATED")} {t("ui.admin.tenants.table.updated", "UPDATED")}
{getSortIcon("updatedAt")} {getSortIcon("updatedAt")}
</div> </div>
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="relative"> <TableBody
{rowVirtualizer.getTotalSize() > 0 && className="relative"
virtualRows.length > 0 && style={
!(isTest && flattenedRows.length < 100) && ( shouldVirtualizeRows
<tr style={{ height: `${virtualRows[0].start}px` }}> ? {
<td colSpan={8} /> display: "grid",
</tr> height: `${rowVirtualizer.getTotalSize()}px`,
)} minWidth: tenantTableMinWidth,
position: "relative",
}
: undefined
}
>
{flattenedRows.length === 0 && !isLoading && ( {flattenedRows.length === 0 && !isLoading && (
<TableRow> <TableRow
style={{
display: "grid",
gridTemplateColumns: tenantTableGridTemplateColumns,
minWidth: tenantTableMinWidth,
}}
>
<TableCell <TableCell
colSpan={8} colSpan={8}
className="py-8 text-center text-muted-foreground" className="py-8 text-center text-muted-foreground"
style={{ gridColumn: "1 / -1" }}
> >
{t( {emptyMessage}
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{isTest && flattenedRows.length < 100 {!shouldVirtualizeRows
? flattenedRows.map((row, index) => renderRow(row, index)) ? flattenedRows.map((row, index) => renderRow(row, index))
: virtualRows.map((virtualRow) => : virtualRows.map((virtualRow) =>
renderRow( 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 && ( {isFetchingNextPage && (
<TableRow> <TableRow
style={{
display: "grid",
gridTemplateColumns: tenantTableGridTemplateColumns,
minWidth: tenantTableMinWidth,
}}
>
<TableCell colSpan={8} className="py-4 text-center"> <TableCell colSpan={8} className="py-4 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<RefreshCw size={16} className="animate-spin" /> <RefreshCw size={16} className="animate-spin" />

View File

@@ -127,7 +127,7 @@ describe("UserListPage search rendering", () => {
renderUserListPage(); renderUserListPage();
await screen.findByText("User 0"); await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const renderCountBeforeTyping = selectRenderCounter.count; const renderCountBeforeTyping = selectRenderCounter.count;
fireEvent.change(searchInput, { target: { value: "u" } }); fireEvent.change(searchInput, { target: { value: "u" } });
@@ -179,7 +179,7 @@ describe("UserListPage search rendering", () => {
renderUserListPage(); renderUserListPage();
await screen.findByText("User 0"); await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const startedAt = performance.now(); const startedAt = performance.now();
fireEvent.change(searchInput, { target: { value: "user 19" } }); fireEvent.change(searchInput, { target: { value: "user 19" } });

View File

@@ -204,14 +204,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<SearchFilterBar <SearchFilterBar
primary={ 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" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
id="user-list-search" id="user-list-search"
name="user-list-search" name="user-list-search"
placeholder={t( placeholder={t(
"ui.admin.users.list.search_placeholder", "ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...", "이름 또는 이메일 검색",
)} )}
className="h-9 pl-9" className="h-9 pl-9"
value={localSearch} value={localSearch}
@@ -1005,7 +1005,7 @@ function UserListPage() {
<TableCell> <TableCell>
<Link <Link
to={`/users/${user.id}`} 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} title={user.name}
> >
{user.name} {user.name}

View File

@@ -1071,6 +1071,7 @@ user = "General User (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "Add Tenant" add = "Add Tenant"
csv_template = "Template" csv_template = "Template"
data_mgmt = "Data Management"
delete_selected = "Delete Selected" delete_selected = "Delete Selected"
export_with_ids = "Include UUIDs" export_with_ids = "Include UUIDs"
export_without_ids = "Export without UUIDs" export_without_ids = "Export without UUIDs"
@@ -1267,10 +1268,21 @@ name = "NAME"
slug = "SLUG" slug = "SLUG"
status = "STATUS" 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] [ui.admin.tenants.table]
actions = "ACTIONS" actions = "ACTIONS"
id = "ID" id = "ID"
members_count = "{{count}} members"
members = "Members" members = "Members"
members_recursive = "Includes descendants"
name = "NAME" name = "NAME"
slug = "SLUG" slug = "SLUG"
status = "STATUS" status = "STATUS"
@@ -1389,7 +1401,7 @@ change_status = "Change {{name}} status"
empty = "No users found." empty = "No users found."
fetch_error = "Failed to fetch user list." fetch_error = "Failed to fetch user list."
search_label = "Search Users" search_label = "Search Users"
search_placeholder = "Search by name or email..." search_placeholder = "Search by name or email"
subtitle = "View and manage system users." subtitle = "View and manage system users."
toggle_status = "{{name}} active status" toggle_status = "{{name}} active status"
title = "User Management" title = "User Management"
@@ -1424,7 +1436,7 @@ remove_success = "Successfully excluded from organization."
[ui.admin.tenants.list] [ui.admin.tenants.list]
search_label = "Search Tenants" search_label = "Search Tenants"
search_placeholder = "Search by name or slug..." search_placeholder = "Search by name, slug, or ID"
title = "Tenant List" title = "Tenant List"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
@@ -1442,12 +1454,18 @@ count = "Registered users"
title = "User Registry" title = "User Registry"
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "ACTIONS" actions = "Actions"
created = "CREATED" created = "Created"
name_email = "NAME / EMAIL" email = "Email"
role = "ROLE" id = "ID"
status = "STATUS" name = "Name"
tenant_dept = "TENANT / DEPT" phone = "Phone"
role = "Role"
status = "Status"
tenant_dept = "Tenant / Dept"
[ui.admin.users]
data_mgmt = "Data Management"
[ui.admin.users.table] [ui.admin.users.table]
email = "Email" email = "Email"
@@ -1531,6 +1549,10 @@ unknown_name = "Unknown User"
logout = "Logout" logout = "Logout"
profile = "My Profile" profile = "My Profile"
[ui.shell.sidebar]
collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.role] [ui.shell.role]
rp_admin = "Service Administrator (RP Admin)" rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)" super_admin = "System Administrator (Super Admin)"

View File

@@ -1074,6 +1074,7 @@ user = "일반 사용자 (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "테넌트 추가" add = "테넌트 추가"
csv_template = "템플릿" csv_template = "템플릿"
data_mgmt = "데이터 관리"
delete_selected = "선택 삭제" delete_selected = "선택 삭제"
export_with_ids = "UUID 포함" export_with_ids = "UUID 포함"
export_without_ids = "UUID 제외 내보내기" export_without_ids = "UUID 제외 내보내기"
@@ -1270,15 +1271,26 @@ name = "NAME"
slug = "SLUG" slug = "SLUG"
status = "STATUS" status = "STATUS"
[ui.admin.tenants.view]
list = "평면 목록"
table = "평면"
tree = "트리"
[ui.admin.tenants.scope]
active = "{{name}} 하위"
pick = "상위 범위 선택"
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "ACTIONS" actions = "ACTIONS"
id = "ID" id = "ID"
members_count = "{{count}}명"
members = "멤버수" members = "멤버수"
name = "NAME" members_recursive = "하위 포함"
slug = "SLUG" name = "이름"
status = "STATUS" slug = "슬러그"
status = "상태"
type = "유형" type = "유형"
updated = "UPDATED" updated = "수정일"
[ui.admin.users] [ui.admin.users]
csv_template = "템플릿 다운로드" csv_template = "템플릿 다운로드"
@@ -1392,7 +1404,7 @@ change_status = "{{name}} 상태 변경"
empty = "검색 결과가 없습니다." empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다." fetch_error = "사용자 목록 조회에 실패했습니다."
search_label = "사용자 검색" search_label = "사용자 검색"
search_placeholder = "이름 또는 이메일 검색..." search_placeholder = "이름 또는 이메일 검색"
subtitle = "시스템 사용자를 조회하고 관리합니다." subtitle = "시스템 사용자를 조회하고 관리합니다."
toggle_status = "{{name}} 활성 상태" toggle_status = "{{name}} 활성 상태"
title = "사용자 관리" title = "사용자 관리"
@@ -1427,7 +1439,7 @@ remove_success = "조직에서 제외되었습니다."
[ui.admin.tenants.list] [ui.admin.tenants.list]
search_label = "테넌트 검색" search_label = "테넌트 검색"
search_placeholder = "테넌트 이름 또는 슬러그 검색..." search_placeholder = "이름 또는 슬러그, ID 검색"
title = "테넌트 목록" title = "테넌트 목록"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
@@ -1445,12 +1457,18 @@ count = "총 {{count}}명의 사용자가 등록되어 있습니다."
title = "사용자 레지스트리" title = "사용자 레지스트리"
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "ACTIONS" actions = "액션"
created = "CREATED" created = "등록일"
name_email = "NAME / EMAIL" email = "이메일"
role = "ROLE" id = "ID"
status = "STATUS" name = "이름"
tenant_dept = "TENANT / DEPT" phone = "전화번호"
role = "역할"
status = "상태"
tenant_dept = "테넌트 / 부서"
[ui.admin.users]
data_mgmt = "데이터 관리"
[ui.admin.users.table] [ui.admin.users.table]
email = "이메일" email = "이메일"
@@ -1534,6 +1552,10 @@ unknown_name = "Unknown User"
logout = "Logout" logout = "Logout"
profile = "내 정보" profile = "내 정보"
[ui.shell.sidebar]
collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.role] [ui.shell.role]
rp_admin = "서비스 관리자 (RP Admin)" rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)" super_admin = "시스템 관리자 (Super Admin)"

View File

@@ -1291,6 +1291,8 @@ slug = ""
status = "" status = ""
[ui.admin.tenants.table] [ui.admin.tenants.table]
members_count = ""
members_recursive = ""
actions = "" actions = ""
id = "" id = ""
members = "" members = ""
@@ -1426,11 +1428,17 @@ title = ""
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "" actions = ""
created = "" created = ""
name_email = "" email = ""
id = ""
name = ""
phone = ""
role = "" role = ""
status = "" status = ""
tenant_dept = "" tenant_dept = ""
[ui.admin.users]
data_mgmt = ""
[ui.admin.users.table] [ui.admin.users.table]
email = "" email = ""
name = "" name = ""
@@ -1513,6 +1521,10 @@ unknown_name = ""
logout = "" logout = ""
profile = "" profile = ""
[ui.shell.sidebar]
collapse = ""
expand = ""
[ui.shell.role] [ui.shell.role]
rp_admin = "" rp_admin = ""
super_admin = "" super_admin = ""

View File

@@ -126,7 +126,7 @@ test.describe("Authentication", () => {
await page.goto("/"); await page.goto("/");
await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute( await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute(
"href", "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", { await expect(page.locator("table")).toContainText("Tenant A", {
timeout: 10000, 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")).toContainText("COMPANY");
await expect(page.locator("table")).not.toContainText("일반 기업"); await expect(page.locator("table")).toContainText("일반 기업");
const headerWhiteSpace = await page const headerWhiteSpace = await page
.locator("table thead th") .locator("table thead th")
@@ -188,16 +190,14 @@ test.describe("Tenants Management", () => {
await page.goto("/tenants"); await page.goto("/tenants");
await page await page
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i)
.fill("team-1"); .fill("team-1");
await expect(page.locator("table")).toContainText("Platform"); await expect(page.locator("table")).toContainText("Platform");
await page.getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i).fill("");
await page await page
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .getByTestId("tenant-internal-id-dept-1")
.fill(""); .locator("xpath=ancestor::tr")
await page
.locator("tbody tr")
.filter({ hasText: "Planning" })
.getByRole("checkbox") .getByRole("checkbox")
.click(); .click();
@@ -291,8 +291,8 @@ test.describe("Tenants Management", () => {
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill(""); await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await page await page
.locator("tbody tr") .getByTestId("tenant-internal-id-company-1")
.filter({ hasText: "Acme" }) .locator("xpath=ancestor::tr")
.getByRole("checkbox") .getByRole("checkbox")
.click(); .click();
@@ -363,7 +363,7 @@ test.describe("Tenants Management", () => {
await page.goto("/tenants"); await page.goto("/tenants");
await expect( await expect(
page.getByText("총 501개의 테넌트가 등록되어 있습니다."), page.getByText("총 500개의 테넌트가 등록되어 있습니다."),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("button", { name: "더 불러오기" })).toHaveCount( await expect(page.getByRole("button", { name: "더 불러오기" })).toHaveCount(
0, 0,

View File

@@ -602,11 +602,11 @@ test.describe("User Management", () => {
await expect(page.getByText("Load User 0")).toBeVisible(); await expect(page.getByText("Load User 0")).toBeVisible();
const initialMs = performance.now() - initialStartedAt; const initialMs = performance.now() - initialStartedAt;
const searchInput = page.getByPlaceholder("이름 또는 이메일 검색..."); const searchInput = page.getByPlaceholder("이름 또는 이메일 검색");
await searchInput.fill("Load User 19999"); await searchInput.fill("Load User 19999");
const searchMs = await page.evaluate(async () => { const searchMs = await page.evaluate(async () => {
const input = Array.from(document.querySelectorAll("input")).find( const input = Array.from(document.querySelectorAll("input")).find(
(candidate) => candidate.placeholder === "이름 또는 이메일 검색...", (candidate) => candidate.placeholder === "이름 또는 이메일 검색",
); );
if (!input) { if (!input) {

View File

@@ -1,7 +1,9 @@
import { ChevronDown, ChevronUp, Copy } from "lucide-react"; import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { getCommonBadgeClasses } from "../../../ui/badge"; import {
import type { CommonBadgeVariant } from "../../../ui/badge"; getCommonBadgeClasses,
type CommonBadgeVariant,
} from "../../../ui/badge";
import { getCommonButtonClasses } from "../../../ui/button"; import { getCommonButtonClasses } from "../../../ui/button";
import { import {
commonStickyTableHeaderClass, commonStickyTableHeaderClass,

View File

@@ -1,3 +1,4 @@
import { Menu, SquareMenu } from "lucide-react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { shellLayoutClasses } from "./layout"; import { shellLayoutClasses } from "./layout";
@@ -14,9 +15,13 @@ export type ShellSidebarNavItem = {
type ShellSidebarProps = { type ShellSidebarProps = {
brandLabel: string; brandLabel: string;
brandTitle: string; brandTitle: string;
brandIcon: ReactNode; brandIcon?: ReactNode;
navContent: ReactNode; navContent: ReactNode;
footerContent: ReactNode; footerContent: ReactNode;
collapsed?: boolean;
onToggleCollapsed?: () => void;
collapseLabel?: string;
expandLabel?: string;
}; };
export function AppSidebar({ export function AppSidebar({
@@ -25,14 +30,57 @@ export function AppSidebar({
brandIcon, brandIcon,
navContent, navContent,
footerContent, footerContent,
collapsed = false,
onToggleCollapsed,
collapseLabel = "Collapse sidebar",
expandLabel = "Expand sidebar",
}: ShellSidebarProps) { }: ShellSidebarProps) {
return ( return (
<aside className={shellLayoutClasses.aside}> <aside
className={
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
}
>
<div> <div>
<div className={shellLayoutClasses.brandSection}> <div
<div className={shellLayoutClasses.brandWrap}> className={
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</div> collapsed
<div> ? 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"> <p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{brandLabel} {brandLabel}
</p> </p>
@@ -40,7 +88,15 @@ export function AppSidebar({
</div> </div>
</div> </div>
</div> </div>
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav> <nav
className={
collapsed
? shellLayoutClasses.navWrapCollapsed
: shellLayoutClasses.navWrap
}
>
{navContent}
</nav>
</div> </div>
<div>{footerContent}</div> <div>{footerContent}</div>

View File

@@ -27,6 +27,8 @@ type ShellProfileSummaryParams = {
export const SHELL_THEME_STORAGE_KEY = "admin_theme"; export const SHELL_THEME_STORAGE_KEY = "admin_theme";
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY; 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 type { ShellSidebarNavItem } from "./AppSidebar";
export { AppSidebar } from "./AppSidebar"; export { AppSidebar } from "./AppSidebar";
export { shellLayoutClasses } from "./layout"; export { shellLayoutClasses } from "./layout";
@@ -52,6 +54,25 @@ export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
writeSessionExpiryEnabled(isEnabled); 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({ export function buildShellProfileSummary({
profileName, profileName,
profileEmail, profileEmail,

View File

@@ -1,22 +1,34 @@
export const shellLayoutClasses = { export const shellLayoutClasses = {
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground", 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: aside:
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur", "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: asideStatic:
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur", "sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
brandSection: brandSection:
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6", "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", 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: 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)]", "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: 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", "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", navWrap: "px-2 pb-4 md:px-3 md:pb-8",
navWrapCollapsed: "px-2 pb-4 md:px-2 md:pb-8",
navMeta: navMeta:
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start", "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", navList: "flex flex-col gap-1",
navItemBase: navItemBase:
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", "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: navItemActive:
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]", "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", 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", "hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
logoutButton: 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", "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: header:
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur", "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
headerElevated: headerElevated:
@@ -31,8 +45,11 @@ export const shellLayoutClasses = {
headerInner: "flex items-center justify-between px-5 py-4 md:px-8", headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
headerTitleWrap: "flex flex-col gap-1", headerTitleWrap: "flex flex-col gap-1",
headerActions: "flex items-center gap-2 text-sm", headerActions: "flex items-center gap-2 text-sm",
headerActionsCollapsed: "flex items-center gap-2 text-sm",
actionButton: actionButton:
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20", "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: sessionBadge:
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex", "hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
profileInitial: profileInitial:

View File

@@ -116,6 +116,24 @@ describe("devfront AppLayout", () => {
expect(document.documentElement.classList.contains("light")).toBe(true); 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 () => { it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
const container = await renderLayout(); const container = await renderLayout();

View File

@@ -19,11 +19,13 @@ import {
buildShellProfileSummary, buildShellProfileSummary,
buildShellSessionStatus, buildShellSessionStatus,
readShellSessionExpiryEnabled, readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme, readShellTheme,
type ShellSidebarNavItem, type ShellSidebarNavItem,
type ShellTranslator, type ShellTranslator,
shellLayoutClasses, shellLayoutClasses,
writeShellSessionExpiryEnabled, writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell"; } from "../../../../common/shell";
import { fetchMe } from "../../features/auth/authApi"; import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
@@ -118,6 +120,9 @@ function AppLayout() {
const isDevelopmentRuntime = import.meta.env.MODE === "development"; const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [, setDevelopmentRenderRevision] = useState(0); const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime), readShellSessionExpiryEnabled(!isDevelopmentRuntime),
@@ -352,26 +357,42 @@ function AppLayout() {
return next; return next;
}); });
}; };
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = ( const sidebarNavContent = (
<div className={shellLayoutClasses.navList}> <div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( {navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
<NavLink const label = t(labelKey, labelFallback);
key={to}
to={to} return (
end={to === "/"} <NavLink
className={({ isActive }) => key={to}
[ to={to}
shellLayoutClasses.navItemBase, end={to === "/"}
isActive className={({ isActive }) =>
? shellLayoutClasses.navItemActive [
: shellLayoutClasses.navItemIdle, shellLayoutClasses.navItemBase,
].join(" ") isSidebarCollapsed
} ? shellLayoutClasses.navItemBaseCollapsed
> : "",
<Icon size={18} /> isActive
<span>{t(labelKey, labelFallback)}</span> ? shellLayoutClasses.navItemActive
</NavLink> : shellLayoutClasses.navItemIdle,
))} ].join(" ")
}
title={label}
aria-label={label}
>
<Icon size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink>
);
})}
</div> </div>
); );
const sidebarFooterContent = ( const sidebarFooterContent = (
@@ -379,22 +400,39 @@ function AppLayout() {
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className={shellLayoutClasses.logoutButton} className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
> >
<LogOut size={18} /> <LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button> </button>
</div> </div>
); );
return ( return (
<div className={shellLayoutClasses.root}> <div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar <AppSidebar
brandLabel={t("ui.dev.brand", "Baron Sign In")} brandLabel={t("ui.dev.brand", "Baron Sign In")}
brandTitle={t("ui.dev.console_title", "Developer Console")} brandTitle={t("ui.dev.console_title", "Developer Console")}
brandIcon={<ShieldHalf size={20} />} brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent} navContent={sidebarNavContent}
footerContent={sidebarFooterContent} footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/> />
<div className={shellLayoutClasses.content}> <div className={shellLayoutClasses.content}>

View File

@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
logout = "Logout" logout = "Logout"
profile = "My Profile" profile = "My Profile"
[ui.shell.sidebar]
collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.role] [ui.shell.role]
rp_admin = "Service Administrator (RP Admin)" rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)" super_admin = "System Administrator (Super Admin)"

View File

@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
logout = "Logout" logout = "Logout"
profile = "내 정보" profile = "내 정보"
[ui.shell.sidebar]
collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.role] [ui.shell.role]
rp_admin = "서비스 관리자 (RP Admin)" rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)" super_admin = "시스템 관리자 (Super Admin)"

View File

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

View File

@@ -55,6 +55,10 @@ services:
build: build:
context: . context: .
dockerfile: ./adminfront/Dockerfile 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 container_name: baron_adminfront
env_file: env_file:
- .env - .env
@@ -80,6 +84,10 @@ services:
build: build:
context: . context: .
dockerfile: ./devfront/Dockerfile 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 container_name: baron_devfront
env_file: env_file:
- .env - .env

View File

@@ -264,6 +264,15 @@ subtitle = "List of owners with top-level permissions for this tenant."
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "{{count}} tenants loaded." 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] [msg.admin.tenants.schema]
empty = "No custom fields defined. Click \\\\\\\"Add Field\\\\\\\" to begin." empty = "No custom fields defined. Click \\\\\\\"Add Field\\\\\\\" to begin."
@@ -1157,11 +1166,13 @@ user = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "Add Tenant" add = "Add Tenant"
csv_template = "Template" csv_template = "Template"
data_mgmt = "Data Management"
delete_selected = "Delete Selected" delete_selected = "Delete Selected"
export_with_ids = "Include UUIDs" export_with_ids = "Include UUIDs"
export_without_ids = "Export without UUIDs" export_without_ids = "Export without UUIDs"
import = "Import" import = "Import"
seed_badge = "Seed" seed_badge = "Seed"
path.root = "Root"
title = "Tenant Registry" title = "Tenant Registry"
view_org_chart = "View Full Org Chart" view_org_chart = "View Full Org Chart"
@@ -1441,9 +1452,14 @@ status = "STATUS"
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "ACTIONS" actions = "ACTIONS"
context = "Parent Path"
id = "ID" id = "ID"
id_copy = "Copy ID"
members = "Members" members = "Members"
members_count = "{{count}} members"
members_recursive = "including descendants"
name = "NAME" name = "NAME"
root = "Top Level"
slug = "SLUG" slug = "SLUG"
status = "STATUS" status = "STATUS"
type = "TYPE" type = "TYPE"
@@ -2586,6 +2602,10 @@ title_remote = "Sign-in Approved"
logout = "Logout" logout = "Logout"
profile = "My Profile" profile = "My Profile"
[ui.shell.sidebar]
collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.profile] [ui.shell.profile]
menu_aria = "Open account menu" menu_aria = "Open account menu"
menu_title = "Account" menu_title = "Account"

View File

@@ -765,6 +765,15 @@ subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "총 {{count}}개 테넌트" 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] [msg.admin.tenants.schema]
empty = "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요." empty = "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요."
@@ -1652,8 +1661,10 @@ user = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "테넌트 추가" add = "테넌트 추가"
data_mgmt = "데이터 관리"
delete_selected = "선택 삭제" delete_selected = "선택 삭제"
seed_badge = "초기 설정" seed_badge = "초기 설정"
path.root = "최상위"
title = "테넌트 목록" title = "테넌트 목록"
view_org_chart = "전체 조직도 보기" view_org_chart = "전체 조직도 보기"
@@ -1904,13 +1915,18 @@ status = "STATUS"
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "ACTIONS" actions = "ACTIONS"
context = "상위 경로"
id = "ID" id = "ID"
id_copy = "ID 복사"
members = "멤버수" members = "멤버수"
name = "NAME" members_count = "{{count}}명"
slug = "SLUG" members_recursive = "하위 포함"
status = "STATUS" name = "이름"
root = "최상위"
slug = "슬러그"
status = "상태"
type = "유형" type = "유형"
updated = "UPDATED" updated = "수정일"
created = "CREATED" created = "CREATED"
created = "CREATED" created = "CREATED"
@@ -3011,6 +3027,10 @@ title_remote = "로그인 승인 완료"
logout = "로그아웃" logout = "로그아웃"
profile = "내 정보" profile = "내 정보"
[ui.shell.sidebar]
collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.profile] [ui.shell.profile]
menu_aria = "계정 메뉴 열기" menu_aria = "계정 메뉴 열기"
menu_title = "계정" menu_title = "계정"

View File

@@ -622,6 +622,15 @@ subtitle = ""
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "" count = ""
scope_results = ""
scope_search_results = ""
search_results = ""
table_hint = ""
tree_hint = ""
[msg.admin.tenants]
empty_scope = ""
empty_search = ""
[msg.admin.tenants.schema] [msg.admin.tenants.schema]
empty = "" empty = ""
@@ -1514,6 +1523,7 @@ user = ""
add = "" add = ""
delete_selected = "" delete_selected = ""
seed_badge = "" seed_badge = ""
path.root = ""
title = "" title = ""
view_org_chart = "" view_org_chart = ""
@@ -1781,9 +1791,14 @@ status = ""
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "" actions = ""
context = ""
id = "" id = ""
id_copy = ""
members = "" members = ""
members_count = ""
members_recursive = ""
name = "" name = ""
root = ""
slug = "" slug = ""
status = "" status = ""
type = "" type = ""
@@ -2890,6 +2905,10 @@ title_remote = ""
logout = "" logout = ""
profile = "" profile = ""
[ui.shell.sidebar]
collapse = ""
expand = ""
[ui.shell.profile] [ui.shell.profile]
menu_aria = "" menu_aria = ""
menu_title = "" menu_title = ""

View File

@@ -128,8 +128,8 @@ run_with_retry() {
return "$exit_code" return "$exit_code"
} }
playwright_install_cmd=(npx playwright install) playwright_install_cmd=(pnpm exec playwright install)
playwright_install_desc="npx playwright install" playwright_install_desc="pnpm exec playwright install"
playwright_project_args=() playwright_project_args=()
has_webkit_host_dependencies() { has_webkit_host_dependencies() {
@@ -179,21 +179,21 @@ has_webkit_host_dependencies() {
} }
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
playwright_install_cmd=(npx playwright install --with-deps) playwright_install_cmd=(pnpm exec playwright install --with-deps)
playwright_install_desc="npx 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 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_cmd=(pnpm exec playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps" playwright_install_desc="pnpm exec playwright install --with-deps"
elif ! has_webkit_host_dependencies; then elif ! has_webkit_host_dependencies; then
playwright_install_cmd=(npx playwright install chromium firefox) playwright_install_cmd=(pnpm exec playwright install chromium firefox)
playwright_install_desc="npx playwright install chromium firefox" playwright_install_desc="pnpm exec playwright install chromium firefox"
playwright_project_args=(--project=chromium --project=firefox) playwright_project_args=(--project=chromium --project=firefox)
{ {
echo "# Adminfront WebKit Skipped" echo "# Adminfront WebKit Skipped"
echo echo
echo "- Reason: WebKit host dependencies are not installed and this user cannot run passwordless sudo." echo "- Reason: WebKit host dependencies are not installed and this user cannot run passwordless sudo."
echo "- Action: Running Chromium and Firefox projects only." 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 } > reports/adminfront-webkit-skipped.md
fi fi