1
0
forked from baron/baron-sso

feat: add hierarchical view toggle to Tenant list page

- Implement view switcher (List vs Hierarchy) using Tabs
- Add TenantHierarchyView component with sidebar tree navigation
- Display 'Same Level Organizations' and 'Sub-Organizations' for the selected node
- Reuse buildTenantFullTree logic for consistent hierarchical data
This commit is contained in:
2026-05-07 15:24:49 +09:00
parent e378fdd3e8
commit 80bb6abb72

View File

@@ -4,8 +4,15 @@ import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
Building2,
ChevronDown,
ChevronRight,
Download, Download,
ExternalLink,
FileSpreadsheet, FileSpreadsheet,
LayoutDashboard,
List,
Network,
Plus, Plus,
RefreshCw, RefreshCw,
Search, Search,
@@ -34,6 +41,8 @@ import {
DialogTitle, DialogTitle,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { Separator } from "../../../components/ui/separator";
import { import {
Table, Table,
TableBody, TableBody,
@@ -42,6 +51,12 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../../components/ui/tabs";
import { import {
type TenantSummary, type TenantSummary,
deleteTenant, deleteTenant,
@@ -70,8 +85,21 @@ type SortConfig = {
direction: "asc" | "desc"; direction: "asc" | "desc";
}; };
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
case "COMPANY_GROUP":
return Network;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:
return Building2;
}
};
function TenantListPage() { function TenantListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
const [selectedIds, setSelectedIds] = React.useState<string[]>([]); const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null); const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
@@ -535,16 +563,39 @@ function TenantListPage() {
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden"> <Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div className="flex items-center gap-6">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant Registry")} {t("ui.admin.tenants.registry.title", "Tenant Registry")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { {t(
"msg.admin.tenants.registry.count",
"총 {{count}}개 테넌트",
{
count: query.data?.total ?? 0, count: query.data?.total ?? 0,
})} },
)}
</CardDescription> </CardDescription>
</div> </div>
<Tabs
value={viewMode}
onValueChange={(v) => setViewMode(v as "list" | "hierarchy")}
className="w-[280px]"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<List className="w-3.5 h-3.5 mr-1.5" />
{t("ui.admin.tenants.view.list", "평면 목록")}
</TabsTrigger>
<TabsTrigger value="hierarchy" className="text-xs">
<Network className="w-3.5 h-3.5 mr-1.5" />
{t("ui.admin.tenants.view.hierarchy", "계층 구조")}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && ( {(errorMsg || fallbackError) && (
@@ -553,6 +604,11 @@ function TenantListPage() {
</div> </div>
)} )}
<Tabs value={viewMode} className="flex-1 flex flex-col min-h-0">
<TabsContent
value="list"
className="flex-1 flex flex-col min-h-0 m-0"
>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table className="min-w-[1180px]"> <Table className="min-w-[1180px]">
@@ -685,8 +741,14 @@ function TenantListPage() {
{tenant.name} {tenant.name}
</Link> </Link>
{isSeedTenant(tenant) && ( {isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]"> <Badge
{t("ui.admin.tenants.seed_badge", "초기 설정")} variant="secondary"
className="text-[10px]"
>
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge> </Badge>
)} )}
</div> </div>
@@ -723,7 +785,9 @@ function TenantListPage() {
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-xs"> <TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt {tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR") ? new Date(tenant.updatedAt).toLocaleString(
"ko-KR",
)
: "-"} : "-"}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -732,6 +796,15 @@ function TenantListPage() {
</Table> </Table>
</div> </div>
</div> </div>
</TabsContent>
<TabsContent
value="hierarchy"
className="flex-1 flex flex-col min-h-0 m-0 overflow-hidden"
>
<TenantHierarchyView tenants={allTenants} />
</TabsContent>
</Tabs>
</CardContent> </CardContent>
</Card> </Card>
@@ -908,4 +981,276 @@ function TenantListPage() {
); );
} }
// --- Internal Support Components ---
const HierarchyNode: React.FC<{
node: TenantNode;
level: number;
selectedId: string | null;
onSelect: (id: string) => void;
isExpandedInitial?: boolean;
}> = ({ node, level, selectedId, onSelect, isExpandedInitial = false }) => {
const [isExpanded, setIsExpanded] = React.useState(isExpandedInitial);
const isSelected = selectedId === node.id;
const hasChildren = node.children && node.children.length > 0;
const TypeIcon = getTenantIcon(node.type);
return (
<div className="flex flex-col">
<button
type="button"
onClick={() => onSelect(node.id)}
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-left transition-colors ${
isSelected
? "bg-primary text-primary-foreground shadow-sm"
: "hover:bg-muted"
}`}
>
<div className="flex items-center min-w-0">
<div className="w-5 flex items-center justify-center mr-1">
{hasChildren ? (
<span
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
onKeyUp={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
setIsExpanded(!isExpanded);
}
}}
className="p-0.5 hover:bg-black/5 rounded cursor-pointer"
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</span>
) : (
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
)}
</div>
<TypeIcon
size={14}
className={`mr-2 shrink-0 ${isSelected ? "text-primary-foreground" : "text-muted-foreground"}`}
/>
<span className="text-xs truncate font-medium">{node.name}</span>
</div>
<Badge
variant={isSelected ? "secondary" : "outline"}
className="ml-2 text-[9px] px-1 h-3.5 min-w-[1.2rem] justify-center"
>
{node.recursiveMemberCount}
</Badge>
</button>
{isExpanded && hasChildren && (
<div className="flex flex-col ml-3 pl-1 border-l border-border/50">
{node.children.map((child) => (
<HierarchyNode
key={child.id}
node={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
};
const TenantHierarchyView: React.FC<{
tenants: TenantSummary[];
}> = ({ tenants }) => {
const navigate = useNavigate();
const { subTree } = React.useMemo(
() => buildTenantFullTree(tenants),
[tenants],
);
const [selectedId, setSelectedId] = React.useState<string | null>(
subTree[0]?.id || null,
);
const tenantMap = React.useMemo(() => {
const m = new Map<string, TenantNode>();
const fill = (nodes: TenantNode[]) => {
for (const n of nodes) {
m.set(n.id, n);
if (n.children) fill(n.children);
}
};
fill(subTree);
return m;
}, [subTree]);
const selectedNode = selectedId ? tenantMap.get(selectedId) : null;
const siblings = React.useMemo(() => {
if (!selectedNode) return [];
if (!selectedNode.parentId) return subTree;
const parent = tenantMap.get(selectedNode.parentId);
return parent?.children ?? [];
}, [selectedNode, subTree, tenantMap]);
return (
<div className="flex-1 flex gap-4 overflow-hidden min-h-0 mt-4">
{/* Sidebar: Tree */}
<Card className="w-72 flex flex-col bg-muted/20 border shadow-none">
<CardHeader className="p-3 border-b bg-muted/10">
<CardTitle className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
</CardTitle>
</CardHeader>
<CardContent className="p-2 overflow-hidden flex-1">
<ScrollArea className="h-full">
<div className="space-y-0.5">
{subTree.map((node) => (
<HierarchyNode
key={node.id}
node={node}
level={0}
selectedId={selectedId}
onSelect={setSelectedId}
isExpandedInitial={true}
/>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Main: Details & Lists */}
<div className="flex-1 flex flex-col gap-4 overflow-hidden">
{selectedNode ? (
<>
<Card className="shadow-none border bg-card">
<CardHeader className="p-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
{React.createElement(getTenantIcon(selectedNode.type), {
size: 24,
})}
</div>
<div>
<CardTitle className="text-lg">
{selectedNode.name}
</CardTitle>
<CardDescription className="text-xs font-mono">
{selectedNode.slug} ({selectedNode.type})
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${selectedNode.id}`)}
>
<ExternalLink size={14} className="mr-2" />
</Button>
</div>
</CardHeader>
</Card>
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
{/* Siblings (Same Depth) */}
<Card className="flex flex-col shadow-none border overflow-hidden">
<CardHeader className="p-3 bg-muted/10 border-b">
<CardTitle className="text-xs font-semibold">
({siblings.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
{siblings.map((s) => (
<div
key={s.id}
onClick={() => setSelectedId(s.id)}
onKeyUp={(e) => {
if (e.key === "Enter" || e.key === " ") {
setSelectedId(s.id);
}
}}
className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors border ${
s.id === selectedId
? "bg-primary/5 border-primary/20"
: "hover:bg-muted border-transparent"
}`}
>
<div className="flex items-center gap-2 min-w-0">
{React.createElement(getTenantIcon(s.type), {
size: 14,
className: "text-muted-foreground",
})}
<span className="text-sm truncate">{s.name}</span>
</div>
<Badge variant="outline" className="text-[10px]">
{s.recursiveMemberCount}
</Badge>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Children */}
<Card className="flex flex-col shadow-none border overflow-hidden">
<CardHeader className="p-3 bg-muted/10 border-b">
<CardTitle className="text-xs font-semibold">
({selectedNode.children.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
{selectedNode.children.map((c) => (
<div
key={c.id}
onClick={() => setSelectedId(c.id)}
onKeyUp={(e) => {
if (e.key === "Enter" || e.key === " ") {
setSelectedId(c.id);
}
}}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted cursor-pointer transition-colors border border-transparent"
>
<div className="flex items-center gap-2 min-w-0">
{React.createElement(getTenantIcon(c.type), {
size: 14,
className: "text-muted-foreground",
})}
<span className="text-sm truncate">{c.name}</span>
</div>
<Badge variant="outline" className="text-[10px]">
{c.recursiveMemberCount}
</Badge>
</div>
))}
{selectedNode.children.length === 0 && (
<div className="py-8 text-center text-xs text-muted-foreground opacity-50">
.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center border-2 border-dashed rounded-xl opacity-30">
.
</div>
)}
</div>
</div>
);
};
export default TenantListPage; export default TenantListPage;