/** * Showcase · table-master-detail — マスター詳細 (V20) * * Gmail-style master/detail split: a master DataTable (left) drives a detail * pane (right). Selecting a row updates the pane with that 従業員's full record. * The split is RESIZABLE — built on @godxjp/ui ResizablePanelGroup, so the * operator can widen the list when scanning or the detail when reviewing. * * Composition map (intent → real @godxjp/ui primitive): * resizable split ........ ResizablePanelGroup + ResizablePanel + ResizableHandle * fixed-track split ...... MasterDetail (tokenized rail + stacking threshold, ) * master list ............ DataTable (compact, clickable rows, selected row) * status cell ............ Badge tone (出勤/遅刻/早退/休暇 — fixed signaling) * detail header .......... Avatar + heading + Badge + action Buttons * detail metadata ........ Descriptions (replaces hand-rolled
) * 打刻 history ............ Timeline * empty (nothing picked) . EmptyState (inside the pane) * * The narrow-viewport fallback (no room for a side pane) is shown too: the same * data rendered as a single master DataTable with the detail stacked under it. * * DNA: compact density, tabular-nums on times/numbers, small headings, fixed * color signaling (success 若竹 / warning 山吹 / attention 朱 / info 群青), * quiet JP copy, no emoji. */ import * as React from "react"; import { Check, Clock, Pencil, UserRound } from "lucide-react"; import { Button, Heading, Text } from "@godxjp/ui/general"; import { Avatar, AvatarFallback, Badge, Card, CardAction, CardContent, CardHeader, CardTitle, DataTable, Descriptions, EmptyState, Timeline, type BadgeProps, type ColumnDef, type TimelineItem, } from "@godxjp/ui/data-display"; import { Flex, MasterDetail, PageContainer, ResizableHandle, ResizablePanel, ResizablePanelGroup, Separator, } from "@godxjp/ui/layout"; type BadgeTone = NonNullable; // ── Domain model ─────────────────────────────────────────────────────────── type AttendanceStatus = "present" | "late" | "early" | "leave"; type Employee = { id: string; code: string; name: string; kana: string; dept: string; role: string; status: AttendanceStatus; clockIn: string; // HH:mm or "—" clockOut: string; workedMin: number; // worked minutes today email: string; shift: string; manager: string; joined: string; punches: TimelineItem[]; }; const STATUS_LABEL: Record = { present: "出勤", late: "遅刻", early: "早退", leave: "休暇", }; // Fixed color signaling — present 若竹 / late 朱(attention via destructive-adjacent? no: // lateness is non-destructive → warning 山吹 is reserved for pending; we use the // attention reading by tone "destructive" only for hard violations). Here: // present → success, late → warning, early → info, leave → muted. const STATUS_TONE: Record = { present: "success", late: "warning", early: "info", leave: "muted", }; const EMPLOYEES: Employee[] = [ { id: "E-1042", code: "1042", name: "佐藤 健", kana: "サトウ ケン", dept: "製造一課", role: "ライン主任", status: "present", clockIn: "08:58", clockOut: "—", workedMin: 392, email: "sato.ken@example.co.jp", shift: "日勤 09:00–18:00", manager: "田中 浩二", joined: "2019-04-01", punches: [ { title: "出勤打刻", time: "08:58", note: "正門ゲート" }, { title: "休憩開始", time: "12:00" }, { title: "休憩終了", time: "12:45", current: true }, ], }, { id: "E-1043", code: "1043", name: "鈴木 美咲", kana: "スズキ ミサキ", dept: "品質保証課", role: "検査員", status: "late", clockIn: "09:24", clockOut: "—", workedMin: 356, email: "suzuki.misaki@example.co.jp", shift: "日勤 09:00–18:00", manager: "高橋 由美", joined: "2021-10-01", punches: [ { title: "出勤打刻", time: "09:24", note: "遅刻 24分 · 電車遅延届あり" }, { title: "休憩開始", time: "12:10" }, { title: "休憩終了", time: "12:55", current: true }, ], }, { id: "E-1044", code: "1044", name: "高橋 大悟", kana: "タカハシ ダイゴ", dept: "製造二課", role: "オペレーター", status: "early", clockIn: "08:55", clockOut: "16:30", workedMin: 415, email: "takahashi.daigo@example.co.jp", shift: "日勤 09:00–18:00", manager: "田中 浩二", joined: "2018-04-01", punches: [ { title: "出勤打刻", time: "08:55" }, { title: "休憩終了", time: "12:50" }, { title: "早退打刻", time: "16:30", note: "私用早退届 承認済み", current: true }, ], }, { id: "E-1045", code: "1045", name: "渡辺 葵", kana: "ワタナベ アオイ", dept: "物流課", role: "出荷担当", status: "leave", clockIn: "—", clockOut: "—", workedMin: 0, email: "watanabe.aoi@example.co.jp", shift: "有給休暇", manager: "高橋 由美", joined: "2022-04-01", punches: [{ title: "有給休暇 取得", time: "終日", note: "事前申請 承認済み", current: true }], }, { id: "E-1046", code: "1046", name: "伊藤 翔", kana: "イトウ ショウ", dept: "製造一課", role: "ライン作業", status: "present", clockIn: "08:51", clockOut: "—", workedMin: 401, email: "ito.sho@example.co.jp", shift: "日勤 09:00–18:00", manager: "田中 浩二", joined: "2020-07-01", punches: [ { title: "出勤打刻", time: "08:51" }, { title: "休憩開始", time: "12:05" }, { title: "休憩終了", time: "12:50", current: true }, ], }, { id: "E-1047", code: "1047", name: "山本 麻衣", kana: "ヤマモト マイ", dept: "品質保証課", role: "主任検査員", status: "present", clockIn: "08:47", clockOut: "—", workedMin: 408, email: "yamamoto.mai@example.co.jp", shift: "日勤 09:00–18:00", manager: "高橋 由美", joined: "2017-04-01", punches: [ { title: "出勤打刻", time: "08:47" }, { title: "休憩開始", time: "11:55" }, { title: "休憩終了", time: "12:40", current: true }, ], }, ]; // ── Formatting helpers ────────────────────────────────────────────────────── function fmtWorked(min: number): string { if (min === 0) return "—"; const h = Math.floor(min / 60); const m = min % 60; return `${h}時間${String(m).padStart(2, "0")}分`; } function initials(name: string): string { // First character of the family name as a compact avatar fallback. return name.trim().charAt(0); } // ── Master columns ────────────────────────────────────────────────────────── const columns: ColumnDef[] = [ { key: "name", header: "従業員", render: (row) => (
{row.name} {row.dept} · {row.role}
), }, { key: "status", header: "状態", align: "center", width: "w-24", render: (row) => ( {STATUS_LABEL[row.status]} ), }, { key: "clockIn", header: "出勤", align: "right", width: "w-20", hiddenOnMobile: true, render: (row) => {row.clockIn}, }, ]; // ── Detail pane ───────────────────────────────────────────────────────────── function EmployeeDetail({ employee }: { employee: Employee }) { return ( {/* Header: identity + status + primary actions */} {initials(employee.name)}
{employee.name} {STATUS_LABEL[employee.status]} {employee.kana} · {employee.id}
{/* Metadata grid */} {employee.code} {employee.dept} {employee.role} {employee.manager} {employee.shift} {employee.clockIn} {employee.clockOut} {fmtWorked(employee.workedMin)} {employee.joined} {employee.email} {/* Punch history */}
); } // ── Showcase ──────────────────────────────────────────────────────────────── export default function Demo() { // Pre-select a row at rest so the master/detail link is visible without // clicking (the late employee — the case an operator would inspect). const [selectedId, setSelectedId] = React.useState("E-1043"); const selected = EMPLOYEES.find((e) => e.id === selectedId) ?? null; return ( {/* ── Resizable master/detail split (Gmail-style) ── */} 本日の出勤状況 2026-06-04 · {EMPLOYEES.length}名 {/* Master list */} row.id} density="compact" selected={selected ? new Set([selected.id]) : new Set()} onRowClick={(row) => { setSelectedId(row.id); }} /> {/* Detail pane */} {selected ? ( ) : ( )} {/* ── Empty detail state (nothing selected) ── Fixed-track split, so the geometry belongs to MasterDetail, not this page: rail="master" keeps the leading 一覧 on the fixed track while the detail surface stays fluid, and collapseBelow="md" stacks 一覧→詳細 once the card body itself is too narrow for two tracks. No grid tracks, no per-breakpoint widths, no page-local spacing. */} 未選択の状態 row.id} density="compact" onRowClick={() => {}} /> } > {/* ── Narrow fallback: stacked list + detail (no side room) ── */} 狭幅レイアウト(縦積み) サイドペインが取れない幅では一覧の下に詳細を重ねます row.id} density="compact" selected={new Set(["E-1042"])} onRowClick={() => {}} /> ); }