Master-detail App

FreeNew

DynamicPage 와 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>
  );
}