/** * Showcase · table-bulk-actions — 一括操作 (V2) * * Pattern: row selection REPLACES the list toolbar with a dedicated bulk-action * bar. When nothing is selected the table shows its normal toolbar (search + * 部署 filter + density). The moment one row is checked, that whole toolbar row * is swapped for a primary-tint bar that: * - states the selected count and offers a cross-page "全 N 件を選択" escalation * - groups the safe batch actions (一括承認 / CSV出力) on the left * - ISOLATES the one destructive action (一括削除, tone 茜/destructive) on the * right, gated behind an AlertDialog confirm — never sat next to the safe ones * - offers 解除 to clear the selection and restore the toolbar * * Built only from real @godxjp/ui primitives — DataTable / Button / Badge / * Checkbox / SearchInput / Select / AlertDialog. The swap + tint bar is an * app-level COMPOSITION around DataTable.Content (see gapNotes): the library's * built-in DataTable.BulkActions sits *inside* the toolbar and has no * cross-page-select / isolated-destructive / tinted-replacement behaviour, so we * compose it from primitives rather than fake a component. * * DNA: compact density, small headings, tabular-nums numerics, fixed colour * signaling (承認済 若竹/success · 申請中 山吹/warning · 要確認 朱→attention via * destructive-restraint, here info 群青 for シフト) , quiet JP copy, no emoji. */ import * as React from "react"; import { CheckCheck, Download, Trash2, X } from "lucide-react"; import { Button, Text } from "@godxjp/ui/general"; import { Badge, type BadgeProps, Card, CardContent, DataTable, type ColumnDef, } from "@godxjp/ui/data-display"; import { SearchInput, Select } from "@godxjp/ui/data-entry"; import { AlertDialog } from "@godxjp/ui/feedback"; import { Flex, PageContainer } from "@godxjp/ui/layout"; import type { DensityProp, SortStateProp } from "@godxjp/ui/props"; // ── Domain model: 勤怠申請 (attendance / leave requests awaiting approval) ────── type BadgeTone = NonNullable; type RequestStatus = "approved" | "pending" | "review" | "shift"; const STATUS_META: Record< RequestStatus, { label: string; tone: Extract } > = { approved: { label: "承認済", tone: "success" }, // 若竹 pending: { label: "申請中", tone: "warning" }, // 山吹 review: { label: "要確認", tone: "destructive" }, // 茜 (差戻し候補) shift: { label: "シフト変更", tone: "info" }, // 群青 }; type AttendanceRequest = { id: string; employee: string; dept: string; type: string; date: string; hours: number; status: RequestStatus; }; const DEPARTMENTS = ["営業部", "開発部", "総務部", "カスタマーサポート"] as const; const REQUESTS: AttendanceRequest[] = [ { id: "REQ-2041", employee: "佐藤 美咲", dept: "営業部", type: "残業申請", date: "2026-06-03", hours: 2.5, status: "pending", }, { id: "REQ-2040", employee: "鈴木 健一", dept: "開発部", type: "有給休暇", date: "2026-06-03", hours: 8.0, status: "approved", }, { id: "REQ-2039", employee: "高橋 さくら", dept: "総務部", type: "遅刻届", date: "2026-06-02", hours: 1.0, status: "review", }, { id: "REQ-2038", employee: "田中 大輔", dept: "カスタマーサポート", type: "シフト変更", date: "2026-06-02", hours: 0, status: "shift", }, { id: "REQ-2037", employee: "伊藤 陽菜", dept: "営業部", type: "早退届", date: "2026-06-01", hours: 3.0, status: "pending", }, { id: "REQ-2036", employee: "渡辺 翔太", dept: "開発部", type: "残業申請", date: "2026-06-01", hours: 4.0, status: "pending", }, { id: "REQ-2035", employee: "山本 結衣", dept: "総務部", type: "有給休暇", date: "2026-05-30", hours: 8.0, status: "approved", }, { id: "REQ-2034", employee: "中村 拓海", dept: "カスタマーサポート", type: "遅刻届", date: "2026-05-30", hours: 0.5, status: "review", }, ]; const TOTAL_ACROSS_PAGES = 124; // server-reported total matching the active filter const hours = new Intl.NumberFormat("ja-JP", { minimumFractionDigits: 1, maximumFractionDigits: 1, }); const columns: ColumnDef[] = [ { key: "id", header: "申請番号", width: "w-28" }, { key: "employee", header: "従業員", sortable: true }, { key: "dept", header: "部署", hiddenOnMobile: true }, { key: "type", header: "種別", hiddenOnMobile: true }, { key: "date", header: "対象日", align: "right", sortable: true }, { key: "hours", header: "時間", align: "right", sortable: true, render: (row) => {row.hours === 0 ? "—" : `${hours.format(row.hours)} h`}, }, { key: "status", header: "状態", align: "center", render: (row) => { const m = STATUS_META[row.status]; return ( {m.label} ); }, }, ]; // ── The bulk-action bar (composition that REPLACES the toolbar) ──────────────── function BulkActionBar({ count, total, pageAllSelected, spanAll, onSelectSpanAll, onApprove, onExport, onDelete, onClear, }: { count: number; total: number; pageAllSelected: boolean; spanAll: boolean; onSelectSpanAll: () => void; onApprove: () => void; onExport: () => void; onDelete: () => void; onClear: () => void; }) { const effective = spanAll ? total : count; return ( {/* left: count + cross-page select-all escalation + safe batch actions. THE `wrap`, AND THE ABSENT `min-w-0`, ARE BOTH LOAD-BEARING (gh#643). This cluster used to carry `min-w-0 flex-1` — "squeeze me to nothing" — while every child inside refuses to be squeezed: two `Button`s and a `whitespace-nowrap` count. At 375 the box duly shrank to 113.6px, the content still ran to x=462, and it painted straight over the destructive cluster beside it — 一括承認 at 264.6→360.2 under 解除 at 272.3→341.0, which axe reported as `target-size` with 19.2×30.2 of the button left unobscured against SC 2.5.8's 24×24. Without `min-w-0` the cluster keeps a real min-content width, and `wrap` is what makes that width small enough to be honest: measured after, 375 puts the two clusters on separate rows (left 307px over two lines, destructive below) and 1440 is unchanged — one 30.2px row, destructive still pushed to x=1217.5 by `flex-1`. */} {effective}件を選択中 {/* cross-page select-all: only offered once the whole page is selected */} {pageAllSelected && (spanAll ? ( フィルタ条件の全{" "} {total} {" "} 件を選択しました ) : ( ))} {/* right: ISOLATED destructive action, kept apart from the safe ones */} ); } // ── The normal list toolbar (search + 部署 filter + density) ──────────────────── function ListToolbar({ query, onQuery, dept, onDept, }: { query: string; onQuery: (q: string) => void; dept: string; onDept: (d: string) => void; }) { return (