/**
* Showcase · case5 — Shift Calendar (シフトカレンダー)
*
* A standalone shift-scheduling screen, served at `/showcase/case5-shift-calendar`.
* Built ENTIRELY from real @godxjp/ui primitives — the reference-design
* `admin/[brandSlug]/employee-shifts/calendar` surface recreated as a "skeleton"
* (intent + look), not a transcription of `comp-calendar.html`.
*
* Composition map (prototype block → @godxjp/ui primitive):
* page chrome ............. AppShell + Sidebar + Topbar
* page header + nav ....... PageContainer (extra slot) + Button + ToggleGroup (月/週/日)
* month jump-to ........... Calendar (date-picker) inside a Popover
* month event grid ........ Card + Table bordered (one
per week, one
per day)
* week time-axis + now-line Card + TimelineGrid (hour axis, day columns, `now` marker)
* day staff×time .......... Card + Timeline (per-staff lane) + EmptyState (understaffed)
* shift legend ............ Card + the wa-iro decorative palette swatches
* date detail (mobile) .... Sheet (master-detail collapses to a drawer < lg)
*
* The two grids each take the primitive that fits their shape. A MONTH is a day matrix, so it is
* a real `Table`. A WEEK is a continuous time axis, so it is `TimelineGrid` — which also places
* the 木曜 早番/遅番 pair side by side instead of stacking one on top of the other, and clips the
* 夜勤 22:00–06:00 at the 24:00 edge while the block still prints its real range. `Calendar` keeps
* its genuine job: a single-date PICKER for jumping to a month.
*
* DNA applied: compact density, small headings (20/18/14/13), fixed color signaling
* (success 若竹 / warning 山吹 / info 群青 / attention 朱 / danger 茜), tabular-nums,
* wa-iro DECORATIVE palette for the 7 shift types (never remapped to a semantic role),
* quiet factual JP copy, 1px-border cards with no rest shadow, no emoji.
*/
import * as React from "react";
import {
CalendarDays,
CalendarRange,
ChevronLeft,
ChevronRight,
Clock3,
LayoutDashboard,
Plus,
CalendarClock,
Users,
AlertTriangle,
CalendarCheck,
} from "lucide-react";
import { Button, Text } from "@godxjp/ui/general";
import {
Badge,
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
EmptyState,
ListRow,
Popover,
PopoverContent,
PopoverTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Timeline,
TimelineGrid,
type TimelineGridColumnProp,
type TimelineGridEventProp,
type TimelineItem,
} from "@godxjp/ui/data-display";
import { Calendar, ToggleGroup, ToggleGroupItem } from "@godxjp/ui/data-entry";
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@godxjp/ui/feedback";
import {
AppShell,
Flex,
PageContainer,
ResponsiveGrid,
Sidebar,
type SidebarSectionProp,
} from "@godxjp/ui/layout";
// ── Shift-type palette ────────────────────────────────────────────────────────
// The 7-color shift palette uses wa-iro DECORATIVE tokens (charts/tags/tenant) —
// never a semantic role. Each pill carries a 2px left accent border + soft tint,
// exactly as `comp-calendar.html` (.sh) specified.
type ShiftKind = "early" | "late" | "night" | "tsushi" | "ot" | "leave" | "holiday";
const SHIFT_META: Record<
ShiftKind,
{ label: string; time: string; cssVar: string; muted?: boolean }
> = {
early: { label: "早番", time: "09:00–17:30", cssVar: "--primary" },
late: { label: "遅番", time: "13:00–22:00", cssVar: "--wa-gunjo" },
night: { label: "夜勤", time: "22:00–06:00", cssVar: "--wa-kon" },
tsushi: { label: "通し", time: "09:00–22:00", cssVar: "--wa-wakatake" },
ot: { label: "残業", time: "OT", cssVar: "--wa-shu" },
leave: { label: "休 / 有給", time: "終日", cssVar: "--wa-nezu", muted: true },
holiday: { label: "祝日", time: "終日", cssVar: "--wa-akane" },
};
/** A shift pill — soft tint + 2px left accent, from a decorative wa-iro token. */
function ShiftPill({
kind,
staff,
className,
}: {
kind: ShiftKind;
staff?: string;
className?: string;
}) {
const meta = SHIFT_META[kind];
return (
{meta.label}
{/* `inherit`, not `muted` (gh#643): the chip is a TINTED surface, and `muted` is an absolute
token that measured 4.46:1 on it. Inheriting lets the chip's own ink win. */}
{staff ? (
{staff}
) : null}
);
}
// ── Month data (2026年5月, 6-week grid = 42 cells) ─────────────────────────────
type DayCell = {
date: number; // 1..31, or 0 for padding
dim?: boolean; // other-month
today?: boolean;
weekday: number; // 0=Sun .. 6=Sat
holiday?: string; // holiday name → 祝日 pill
shifts: Array<{ kind: ShiftKind; staff: string }>;
};
// May 2026 starts on a Friday (weekday index 5).
const MAY_HOLIDAYS: Record = {
3: "憲法記念日",
4: "みどりの日",
5: "こどもの日",
};
// A handful of representative shift assignments across the month.
const SHIFTS_BY_DATE: Record> = {
1: [
{ kind: "early", staff: "田中" },
{ kind: "late", staff: "佐藤" },
],
2: [{ kind: "tsushi", staff: "鈴木" }],
7: [
{ kind: "early", staff: "田中" },
{ kind: "late", staff: "高橋" },
{ kind: "night", staff: "伊藤" },
],
8: [
{ kind: "early", staff: "佐藤" },
{ kind: "ot", staff: "田中" },
],
12: [{ kind: "leave", staff: "鈴木" }],
14: [
{ kind: "early", staff: "田中" },
{ kind: "late", staff: "高橋" },
{ kind: "tsushi", staff: "佐藤" },
{ kind: "night", staff: "伊藤" },
],
15: [
{ kind: "early", staff: "佐藤" },
{ kind: "late", staff: "鈴木" },
],
20: [{ kind: "tsushi", staff: "高橋" }],
22: [
{ kind: "early", staff: "田中" },
{ kind: "ot", staff: "佐藤" },
],
28: [{ kind: "night", staff: "伊藤" }],
};
const TODAY = 14; // 2026-05-14, the "today" / now-line anchor.
function buildMonth(): DayCell[] {
const startPad = 5; // May 1, 2026 = Friday
const daysInMonth = 31;
const cells: DayCell[] = [];
// Leading padding (late April), dim.
for (let i = 0; i < startPad; i++) {
const d = 27 + i; // 27..30 Apr (4 cells) + 1 spillover handled by length
cells.push({ date: d, dim: true, weekday: i, shifts: [] });
}
for (let d = 1; d <= daysInMonth; d++) {
const weekday = (startPad + d - 1) % 7;
cells.push({
date: d,
today: d === TODAY,
weekday,
holiday: MAY_HOLIDAYS[d],
shifts: SHIFTS_BY_DATE[d] ?? [],
});
}
// Trailing padding to fill the last week (early June), dim.
let next = 1;
while (cells.length % 7 !== 0) {
cells.push({
date: next,
dim: true,
weekday: cells.length % 7,
shifts: [],
});
next++;
}
return cells;
}
const MONTH_CELLS = buildMonth();
const WEEKDAY_HEAD = ["日", "月", "火", "水", "木", "金", "土"];
/** 42 day cells as six calendar rows — the month grid is a real
, one
per week. */
const MONTH_WEEKS = Array.from({ length: MONTH_CELLS.length / 7 }, (_, w) =>
MONTH_CELLS.slice(w * 7, w * 7 + 7),
);
// ── Week view (2026-05-11 〜 05-17), time axis 06:00–24:00 ─────────────────────
// The week axis is a real `TimelineGrid`: columns are days, blocks are placed by start time and
// duration, and two shifts that overlap are laid out side by side by the primitive.
const WEEK_COLUMNS: TimelineGridColumnProp[] = [
{ date: 11, weekday: 1 },
{ date: 12, weekday: 2 },
{ date: 13, weekday: 3 },
{ date: 14, weekday: 4, current: true },
{ date: 15, weekday: 5 },
{ date: 16, weekday: 6 },
{ date: 17, weekday: 0 },
].map(({ date, weekday, current }) => ({
id: `05-${date}`,
label: (
{WEEKDAY_HEAD[weekday]} {date}
),
current,
}));
// 夜勤 22:00→06:00: an end at or before the start means the shift runs into the next day, so the
// grid clips it at the 24:00 edge and still prints the real range on the block.
const WEEK_SHIFTS: TimelineGridEventProp[] = [
{ id: "w1", columnId: "05-14", start: "09:00", end: "17:30", kind: "early", staff: "田中" },
{ id: "w2", columnId: "05-14", start: "13:00", end: "22:00", kind: "late", staff: "高橋" },
{ id: "w3", columnId: "05-14", start: "22:00", end: "06:00", kind: "night", staff: "伊藤" },
{ id: "w4", columnId: "05-11", start: "09:00", end: "22:00", kind: "tsushi", staff: "佐藤" },
{ id: "w5", columnId: "05-15", start: "09:00", end: "17:30", kind: "early", staff: "佐藤" },
{ id: "w6", columnId: "05-15", start: "18:00", end: "21:00", kind: "ot", staff: "鈴木" },
{ id: "w7", columnId: "05-12", start: "06:00", end: "22:00", kind: "leave", staff: "鈴木" },
].map(({ kind, staff, ...event }) => ({
...event,
title: SHIFT_META[kind as ShiftKind].label,
description: staff,
color: `var(${SHIFT_META[kind as ShiftKind].cssVar})`,
}));
// ── Day view (2026-05-14) — per-staff lanes via Timeline + understaffed gap ───
const DAY_LANES: Array<{ staff: string; role: string; items: TimelineItem[] }> = [
{
staff: "田中 美咲",
role: "店長",
items: [
{ title: "早番 · 09:00 開始", time: "09:00", current: true },
{ title: "休憩 12:00–13:00", time: "12:00" },
{ title: "早番 · 17:30 終了", time: "17:30" },
],
},
{
staff: "高橋 健",
role: "スタッフ",
items: [
{ title: "遅番 · 13:00 開始", time: "13:00" },
{ title: "休憩 17:00–17:45", time: "17:00" },
{ title: "遅番 · 22:00 終了", time: "22:00" },
],
},
{
staff: "伊藤 翔",
role: "スタッフ",
items: [
{ title: "夜勤 · 22:00 開始", time: "22:00", current: true },
{ title: "夜勤 · 翌 06:00 終了", time: "06:00" },
],
},
];
// ── Sidebar ───────────────────────────────────────────────────────────────────
const NAV_SECTIONS: SidebarSectionProp[] = [
{
label: "シフト",
items: [
{ id: "calendar", label: "シフトカレンダー", icon: CalendarRange },
{ id: "shifts", label: "従業員シフト", icon: Users, badge: 5 },
{ id: "requests", label: "変更申請", icon: CalendarClock, badge: 3 },
],
},
{
label: "勤怠",
items: [
{ id: "dashboard", label: "ダッシュボード", icon: LayoutDashboard },
{ id: "logs", label: "打刻ログ", icon: Clock3 },
],
},
];
type ViewMode = "month" | "week" | "day";
export default function ShiftCalendarShowcase() {
const [activeNav, setActiveNav] = React.useState("calendar");
const [view, setView] = React.useState("month");
const [jumpMonth, setJumpMonth] = React.useState(new Date(2026, 4, 14));
const [pickerOpen, setPickerOpen] = React.useState(false);
// Master-detail: tapping a day opens a Sheet (works at any width; on lg the
// grid is the primary surface and the Sheet is an on-demand detail).
const [detailDate, setDetailDate] = React.useState(null);
const sidebar = (
);
return (
シフト管理}
topbarRight={
famgia · 渋谷店
}
>
{/* Month nav segment */}
{
setJumpMonth(d);
setPickerOpen(false);
}}
/>
{/* 月/週/日 segmented view switch */}
{
if (v) setView(v as ViewMode);
}}
aria-label="表示単位"
>
日週月
}
>
{/* ── Shift-type legend (the 7-color wa-iro palette) ── */}
シフト区分
和色(装飾用)
{(Object.keys(SHIFT_META) as ShiftKind[]).map((k) => (
{SHIFT_META[k].label}
{SHIFT_META[k].time}
))}
{/* ── MONTH view ── */}
{view === "month" && }
{/* ── WEEK view ── */}
{view === "week" && }
{/* ── DAY view ── */}
{view === "day" && }
{/* Master-detail: day detail as a Sheet (drawer) */}
!o && setDetailDate(null)}>
{detailDate ? `2026年5月${detailDate.date}日` : ""}
{detailDate ? ` (${WEEKDAY_HEAD[detailDate.weekday]})` : ""}
{detailDate?.holiday ? `祝日 · ${detailDate.holiday}` : "この日のシフト割り当て"}
{detailDate && detailDate.shifts.length > 0 ? (
{detailDate.shifts.map((s, i) => (
}
trailing={
{SHIFT_META[s.kind].time}
}
/>
))}
) : (
)}
);
}
// ── Month grid — a real Table: one
per week, one
per day ────────────
function MonthGrid({ onPick }: { onPick: (d: DayCell) => void }) {
return (
月
6週間 · 31日
{/* Weekday head — Sun→danger, Sat→info; 42 day cells as six