/**
* 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={() => {}}
/>
);
}