Master-detail App
FreeNewDynamicPage 와 Flexible Column Layout 을 합친 마스터-디테일 흐름 (SAP UI5 디자인 패턴 참고). 주문 관리·이슈 트래커·메일 클라이언트.
Live preview
Code
"use client";
import * as React from "react";
import {
FlexibleColumnLayout,
FCLLayoutSwitcher,
FCLColumnHeader,
getBackLayout,
type FCLLayout,
} from "@/components/ui/flexible-column-layout";
import {
DynamicPage,
DynamicPageTitle,
DynamicPageHeader,
DynamicPageContent,
DynamicPageFooter,
} from "@/components/ui/dynamic-page";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
/**
* Master-detail template — "DynamicPage + FCL 조합" 패턴 (SAP UI5 참고).
*
* 구조:
* FCL
* beginColumn = Order 리스트 (Master)
* midColumn = DynamicPage (Order 상세) — title + KPI header + content + footer
* endColumn = DynamicPage (Item 상세) — 디테일의 디테일
*
* 흐름:
* 1. 목록에서 Order 선택 → mid 로 상세 표시 (TwoColumnsMidExpanded)
* 2. 상세 안의 Item 클릭 → end 로 Item 디테일 표시 (ThreeColumnsEndExpanded)
* 3. ← 뒤로가기 또는 layout switcher 로 자유 전환
*/
interface Order {
id: string;
number: string;
customer: string;
total: number;
status: "draft" | "approved" | "fulfilled";
items: OrderItem[];
}
interface OrderItem {
sku: string;
name: string;
qty: number;
price: number;
}
const ORDERS: Order[] = [
{
id: "1",
number: "ORD-2026-0042",
customer: "Acme Inc.",
total: 2450,
status: "approved",
items: [
{ sku: "SKU-1001", name: "Wireless Headphones", qty: 5, price: 199 },
{ sku: "SKU-1002", name: "USB-C Hub", qty: 10, price: 59 },
{ sku: "SKU-1003", name: "Mechanical Keyboard", qty: 3, price: 249 },
],
},
{
id: "2",
number: "ORD-2026-0041",
customer: "Globex Co.",
total: 1280,
status: "fulfilled",
items: [
{ sku: "SKU-2001", name: "4K Monitor 27\"", qty: 2, price: 549 },
{ sku: "SKU-2002", name: "Desk Mat", qty: 4, price: 49 },
],
},
{
id: "3",
number: "ORD-2026-0040",
customer: "Initech",
total: 730,
status: "draft",
items: [
{ sku: "SKU-3001", name: "Webcam HD", qty: 5, price: 99 },
{ sku: "SKU-3002", name: "Mic Stand", qty: 5, price: 49 },
],
},
];
function statusVariant(s: Order["status"]) {
if (s === "approved") return "secondary" as const;
if (s === "fulfilled") return "outline" as const;
return "destructive" as const;
}
export function MasterDetailTemplate() {
const [layout, setLayout] = React.useState<FCLLayout>("OneColumn");
const [selectedOrderId, setSelectedOrderId] = React.useState<string | null>(null);
const [selectedItemSku, setSelectedItemSku] = React.useState<string | null>(null);
const selectedOrder = ORDERS.find((o) => o.id === selectedOrderId);
const selectedItem = selectedOrder?.items.find((i) => i.sku === selectedItemSku);
const goBack = () => {
const back = getBackLayout(layout);
if (back) setLayout(back);
if (layout === "ThreeColumnsEndExpanded") setSelectedItemSku(null);
if (layout === "TwoColumnsMidExpanded") setSelectedOrderId(null);
};
return (
<div className="w-full space-y-3">
<FCLLayoutSwitcher
layout={layout}
onLayoutChange={setLayout}
allowed={[
"OneColumn",
"TwoColumnsMidExpanded",
"ThreeColumnsMidExpanded",
"ThreeColumnsEndExpanded",
]}
/>
<FlexibleColumnLayout
layout={layout}
onLayoutDowngrade={setLayout}
className="h-[640px]"
beginColumn={
<div className="h-full flex flex-col">
<FCLColumnHeader title="Orders" description={`${ORDERS.length} total`} />
<ul className="divide-y divide-border flex-1 overflow-y-auto">
{ORDERS.map((o) => (
<li key={o.id}>
<button
type="button"
onClick={() => {
setSelectedOrderId(o.id);
setSelectedItemSku(null);
setLayout("TwoColumnsMidExpanded");
}}
className={`w-full text-left px-4 py-3 hover:bg-muted/50 transition-colors ${
selectedOrderId === o.id ? "bg-muted/70" : ""
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{o.number}</span>
<Badge variant={statusVariant(o.status)} className="capitalize text-xs">
{o.status}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span className="truncate">{o.customer}</span>
<span className="font-mono">${o.total.toLocaleString()}</span>
</div>
</button>
</li>
))}
</ul>
</div>
}
midColumn={
selectedOrder ? (
<DynamicPage>
<DynamicPageTitle
title={selectedOrder.number}
subtitle={`Customer: ${selectedOrder.customer}`}
breadcrumb={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" onClick={(e) => { e.preventDefault(); goBack(); }}>
Orders
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{selectedOrder.number}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
}
actions={
<>
<Badge variant={statusVariant(selectedOrder.status)} className="capitalize">
{selectedOrder.status}
</Badge>
<Button size="sm" variant="outline" onClick={goBack}>
← Back
</Button>
<Button size="sm">Edit</Button>
</>
}
/>
<DynamicPageHeader>
<div className="grid grid-cols-3 gap-6">
<Stat label="Total" value={`$${selectedOrder.total.toLocaleString()}`} />
<Stat label="Items" value={String(selectedOrder.items.length)} />
<Stat label="Status" value={selectedOrder.status} />
</div>
</DynamicPageHeader>
<DynamicPageContent>
<h2 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wider">
Items
</h2>
<ul className="space-y-2">
{selectedOrder.items.map((item) => (
<li key={item.sku}>
<button
type="button"
onClick={() => {
setSelectedItemSku(item.sku);
setLayout("ThreeColumnsEndExpanded");
}}
className={`w-full text-left rounded-md border border-border bg-card px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 ${
selectedItemSku === item.sku ? "border-primary bg-muted/40" : ""
}`}
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground font-mono">{item.sku}</p>
</div>
<div className="text-right shrink-0 ml-3">
<p className="font-mono">${(item.qty * item.price).toLocaleString()}</p>
<p className="text-xs text-muted-foreground">
{item.qty} × ${item.price}
</p>
</div>
</div>
</button>
</li>
))}
</ul>
</DynamicPageContent>
<DynamicPageFooter>
<Button variant="outline" size="sm">Cancel</Button>
<Button size="sm">Save changes</Button>
</DynamicPageFooter>
</DynamicPage>
) : (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
Select an order
</div>
)
}
endColumn={
selectedItem ? (
<DynamicPage>
<DynamicPageTitle
title={selectedItem.name}
subtitle={selectedItem.sku}
actions={
<Button size="sm" variant="ghost" onClick={() => {
setSelectedItemSku(null);
setLayout("TwoColumnsMidExpanded");
}}>
Close
</Button>
}
/>
<DynamicPageContent>
<div className="space-y-4 max-w-sm">
<div className="space-y-1.5">
<Label htmlFor="item-name">Name</Label>
<Input id="item-name" defaultValue={selectedItem.name} className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="item-qty">Quantity</Label>
<Input id="item-qty" type="number" defaultValue={selectedItem.qty} className="h-9" />
</div>
<div className="space-y-1.5">
<Label htmlFor="item-price">Unit price</Label>
<Input id="item-price" type="number" defaultValue={selectedItem.price} className="h-9" />
</div>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground mb-0.5">Subtotal</p>
<p className="text-lg font-semibold tabular-nums">
${(selectedItem.qty * selectedItem.price).toLocaleString()}
</p>
</div>
</div>
</DynamicPageContent>
<DynamicPageFooter>
<Button variant="outline" size="sm">Discard</Button>
<Button size="sm">Apply</Button>
</DynamicPageFooter>
</DynamicPage>
) : (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
Select an item
</div>
)
}
/>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-base font-semibold tabular-nums capitalize">{value}</p>
</div>
);
}