forked from baron/baron-sso
Merge pull request 'feature/af-tenant-ui' (#1024) from feature/af-tenant-ui into dev
Reviewed-on: baron/baron-sso#1024
This commit is contained in:
@@ -127,6 +127,22 @@ describe("admin AppLayout", () => {
|
|||||||
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" } });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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$/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "계정"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user