import { useMemo } from "react"; import type { CSSProperties } from "react"; import { styled } from "@mui/material/styles"; import Box from "@mui/material/Box"; import { GridData, ResolvedCell, planCategoryLabel, categoryMap, PlanCategory, } from "../lib/queries/plan"; import type { Project } from "../lib/queries/projects"; import PlanRangeChips from "./PlanRangeChips"; import { LoadingState } from "../ui"; import { fonts } from "../theme"; /** * The planner surface, fully MUI/theme-driven (replaces the bespoke plan.css * grid styling). All colors come from the theme palette via `theme.vars`, so it * adapts to light/dark automatically; category colors come from the DB and are * injected per cell as the `--cat-color` CSS variable. Class names from the JSX * below are styled here as scoped nested selectors. */ const PlanGridRoot = styled(Box)(({ theme }) => ({ position: "relative", overflow: "auto", WebkitOverflowScrolling: "touch", overscrollBehavior: "contain", background: theme.vars!.palette.background.default, border: `1px solid ${theme.vars!.palette.divider}`, borderRadius: 14, boxShadow: theme.shadows[2], maxHeight: "calc(100dvh - 240px)", isolation: "isolate", "& .plan-grid": { position: "relative", zIndex: 1, borderCollapse: "separate", borderSpacing: 0, width: "100%", fontSize: "12.5px", color: theme.vars!.palette.text.primary, fontFamily: fonts.body, }, "& col.plan-grid-date-col": { width: 88 }, "& tbody tr td + td": { borderLeft: `1px solid ${theme.vars!.palette.divider}`, }, // Sticky header "& thead th": { position: "sticky", top: 0, zIndex: 3, padding: "14px 12px 12px", background: theme.vars!.palette.background.paper, color: theme.vars!.palette.text.primary, borderBottom: `1px solid ${theme.vars!.palette.divider}`, textAlign: "left", fontWeight: 600, whiteSpace: "nowrap", }, "& thead th.plan-grid-date-col": { left: 0, zIndex: 4, minWidth: 88, padding: "12px 10px 10px 14px", fontSize: "10.5px", fontWeight: 700, letterSpacing: "0.18em", textTransform: "uppercase", color: theme.vars!.palette.text.secondary, }, "& .plan-person-head": { display: "inline-flex", alignItems: "center", gap: 9, minWidth: 0, }, "& .plan-person-dot": { flexShrink: 0, width: 9, height: 9, borderRadius: "50%", background: theme.vars!.palette.action.disabled, }, "& .plan-person-name": { display: "inline-flex", flexDirection: "column", minWidth: 0, lineHeight: 1.1, }, "& .plan-person-name strong": { fontWeight: 600, fontSize: 13, color: theme.vars!.palette.text.primary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 180, }, "& .plan-person-name small": { fontSize: 10, fontWeight: 500, color: theme.vars!.palette.text.disabled, letterSpacing: "0.04em", textTransform: "uppercase", fontFamily: fonts.mono, marginTop: 1, }, // Sticky date column "& tbody td.plan-grid-date-col": { position: "sticky", left: 0, zIndex: 1, background: theme.vars!.palette.background.paper, fontWeight: 500, color: theme.vars!.palette.text.secondary, whiteSpace: "nowrap", minWidth: 88, padding: "9px 10px 9px 14px", borderRight: `1px solid ${theme.vars!.palette.divider}`, borderBottom: `1px solid ${theme.vars!.palette.divider}`, }, "& .plan-date-stamp": { display: "flex", flexDirection: "column", alignItems: "flex-start", lineHeight: 1.1, gap: 1, }, "& .plan-date-daynum": { fontFamily: fonts.mono, fontSize: 17, fontWeight: 600, color: theme.vars!.palette.text.primary, fontVariantNumeric: "tabular-nums", lineHeight: 1.05, }, "& .plan-date-dow": { fontSize: 10, fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", color: theme.vars!.palette.text.disabled, lineHeight: 1.3, }, // Today "& tbody tr.is-today td.plan-grid-date-col": { background: theme.vars!.palette.background.paper, "&::before": { content: '""', position: "absolute", left: 0, top: 6, bottom: 6, width: 3, borderRadius: "0 2px 2px 0", background: theme.vars!.palette.primary.main, boxShadow: `0 0 14px rgba(${theme.vars!.palette.primary.mainChannel} / 0.3)`, }, "& .plan-date-daynum": { color: theme.vars!.palette.primary.main }, }, "& tbody tr.is-today td:not(.plan-grid-date-col)": { background: `rgba(${theme.vars!.palette.primary.mainChannel} / 0.06)`, }, // Body cells "& tbody td": { padding: 0, borderBottom: `1px solid ${theme.vars!.palette.divider}`, verticalAlign: "stretch", minWidth: 168, background: theme.vars!.palette.background.paper, }, // Weekend "& tr.plan-grid-weekend td": { background: `rgba(${theme.vars!.palette.text.primaryChannel} / 0.03)`, }, "& tr.plan-grid-weekend td.plan-grid-date-col": { background: theme.vars!.palette.background.paper, color: theme.vars!.palette.text.disabled, "& .plan-date-dow": { color: theme.vars!.palette.warning.main }, "& .plan-date-daynum": { color: theme.vars!.palette.text.secondary }, }, // The cell button + marker tape "& .plan-cell": { position: "relative", display: "block", width: "100%", height: "100%", minHeight: 60, textAlign: "left", background: "transparent", border: 0, borderRadius: 0, padding: "9px 11px 10px 16px", cursor: "pointer", font: "inherit", color: "inherit", transition: "background 150ms ease, transform 150ms ease", "&::before": { content: '""', position: "absolute", left: 4, top: 8, bottom: 8, width: 3, borderRadius: 2, background: `var(--cat-color, ${theme.vars!.palette.divider})`, transition: "background 150ms ease, box-shadow 150ms ease, transform 150ms ease", }, "&:hover": { background: theme.vars!.palette.action.hover }, "&:hover::before": { transform: "scaleX(1.4)", boxShadow: "0 0 12px currentColor", }, "&:active": { transform: "translateY(0.5px)" }, "&:focus-visible": { outline: "none", boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, inset 0 0 0 4px ${theme.vars!.palette.background.paper}`, }, }, "& .plan-cell--readonly": { cursor: "default", "&:hover": { background: "transparent" }, "&:hover::before": { transform: "none", boxShadow: "none" }, }, // A read-only cell that HAS data is still clickable (view mode). "& .plan-cell--readonly:not(.plan-cell--empty):not(.plan-cell--past)": { cursor: "pointer", "&:hover": { background: theme.vars!.palette.action.hover }, "&:hover::before": { transform: "scaleX(1.4)", boxShadow: "0 0 12px currentColor", }, }, "& .plan-cell--past": { cursor: "default", opacity: 0.55, "&:hover": { background: "transparent" }, "&:hover::before": { transform: "none", boxShadow: "none" }, }, // Empty cells: dashed tape + a "+" hover hint. "& .plan-cell--empty::before": { background: "transparent", border: `1px dashed ${theme.vars!.palette.divider}`, width: 2, left: 5, }, "& .plan-cell--empty:hover::before": { background: theme.vars!.palette.primary.main, borderColor: theme.vars!.palette.primary.main, transform: "scaleX(1.6)", }, "& .plan-cell--empty::after": { content: '"+"', position: "absolute", inset: 0, display: "grid", placeItems: "center", color: theme.vars!.palette.text.disabled, opacity: 0, fontSize: 18, fontWeight: 300, pointerEvents: "none", transition: "opacity 150ms ease", }, "& .plan-cell--empty:hover::after": { opacity: 0.7, color: theme.vars!.palette.primary.main, }, "& .plan-cell--empty.plan-cell--readonly::after": { content: "none" }, // One-shot mutation pulse "@keyframes plan-cell-pulse": { "0%": { boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 0 rgba(${theme.vars!.palette.primary.mainChannel} / 0.4)`, }, "60%": { boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 6px transparent`, }, "100%": { boxShadow: "inset 0 0 0 2px transparent, 0 0 0 0 transparent" }, }, "& .plan-cell--pulse": { animation: "plan-cell-pulse 600ms ease-out" }, // Chips inside a cell "& .plan-chip-block": { display: "flex", flexDirection: "column", gap: 4, textAlign: "left", width: "100%", minWidth: 0, }, "& .plan-chip-line": { display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap", minWidth: 0, }, "& .plan-chip": { display: "inline-flex", alignItems: "center", gap: 5, borderRadius: 4, padding: "2px 7px", fontSize: "10.5px", fontWeight: 600, letterSpacing: "0.04em", textTransform: "uppercase", color: `var(--cat-color, ${theme.vars!.palette.text.primary})`, background: `color-mix(in srgb, var(--cat-color, transparent) 12%, ${theme.vars!.palette.background.paper})`, border: `1px solid color-mix(in srgb, var(--cat-color, ${theme.vars!.palette.divider}) 35%, transparent)`, whiteSpace: "nowrap", }, "& .plan-chip-project": { fontFamily: fonts.mono, fontSize: "12.5px", fontWeight: 600, color: theme.vars!.palette.text.primary, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "100%", }, "& .plan-cell-note": { display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", marginTop: 1, fontSize: "11.5px", color: theme.vars!.palette.text.secondary, lineHeight: 1.35, fontStyle: "italic", }, "@media (max-width: 480px)": { "& col.plan-grid-date-col": { width: 72 }, "& tbody td": { minWidth: 132 }, "& .plan-cell": { minHeight: 56 }, }, // Touch: show a faint persistent "+" on empty editable cells. "@media (hover: none)": { "& .plan-cell--empty:not(.plan-cell--readonly)::after": { opacity: 0.26, color: theme.vars!.palette.text.disabled, }, }, "@media (prefers-reduced-motion: reduce)": { "& .plan-cell, & .plan-cell::before, & .plan-cell::after": { transition: "none", }, "& .plan-cell--pulse": { animation: "none" }, }, })); interface Props { data: GridData | undefined; canEdit: boolean; projects: Project[]; categories: PlanCategory[]; // The (userId, date) of the most recent successful mutation. The // matching cell gets a one-shot highlight pulse. `nonce` is part of // the value so back-to-back mutations on the same cell re-trigger // the animation (React needs a new key to re-mount the class). pulseKey?: { userId: number; date: string; nonce: number } | null; onCellClick: ( userId: number, date: string, cell: ResolvedCell | null, ) => void; } // Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday). // Used in the date column "stamp" so the day name reads in full rather // than as a 2-letter abbreviation — easier to scan when the planner // spans several weeks. const CZECH_WEEKDAYS = [ "Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota", ]; function eachDay(from: string, to: string): string[] { const out: string[] = []; const a = new Date(from + "T00:00:00.000Z"); const b = new Date(to + "T00:00:00.000Z"); for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) { out.push(d.toISOString().slice(0, 10)); } return out; } function czechWeekday(dateStr: string): string { return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()]; } function isWeekend(dateStr: string): boolean { const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay(); return day === 0 || day === 6; } // Today's local date as YYYY-MM-DD. Used to mark the current row and to // gate past-day editing. Uses local time (matches the project's date // conventions in CLAUDE.md and the server's `assertNotPastDate` guard in // src/services/plan.service.ts). function todayIso(): string { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } // True when the given YYYY-MM-DD cell date is strictly before today (local). // Used to gate the create / edit UI on past days — the server still enforces // the rule with a 403, but the client should never put the user in the // "I clicked, modal opened, error toast" state in the first place. function isPastDate(dateStr: string, today: string): boolean { return dateStr < today; } // Split a full name into first / last for the column header. function splitName(full: string): { first: string; last: string } { const parts = full.trim().split(/\s+/); if (parts.length === 1) return { first: parts[0], last: "" }; return { first: parts[0], last: parts.slice(1).join(" ") }; } // Short role label for the column header sub-line. function shortRole(role: string | null): string { if (!role) return ""; // Keep it brief — column header is tight if (/^admin$/i.test(role)) return "Admin"; if (/^viewer$/i.test(role)) return "Viewer"; return role.length > 10 ? role.slice(0, 9) + "…" : role; } export default function PlanGrid({ data, canEdit, projects, categories, pulseKey, onCellClick, }: Props) { const today = useMemo(() => todayIso(), []); const catMap = useMemo(() => categoryMap(categories), [categories]); if (!data) return ; const days = eachDay(data.date_from, data.date_to); const users = data.users; // pulseKey?.nonce is included in the data-pulse attribute so a second // mutation on the same cell re-triggers the animation (CSS animations // don't restart unless the keyframe applies to a fresh element/class // binding). const pulseAttr = pulseKey ? `${pulseKey.userId}|${pulseKey.date}|${pulseKey.nonce}` : undefined; return ( {/* The colgroup is what actually controls column width in a table — `min-width` on `{users.map((u) => ( ))} {users.map((u) => { const { first, last } = splitName(u.full_name); return ( ); })} {days.map((date) => { const dow = czechWeekday(date); const dayNum = date.slice(8, 10); const isToday = date === today; const trCls = [ isWeekend(date) ? "plan-grid-weekend" : "", isToday ? "is-today" : "", ] .filter(Boolean) .join(" "); return ( {users.map((u) => { const cell = data.cells[u.id]?.[date] ?? null; const past = isPastDate(date, today); // Past-day cells are read-only in the UI: an empty past // cell is non-interactive (no create modal, no "+" hint), // a past cell with data opens in view mode only. The // server still enforces the past-date rule with a 403 // (defense in depth), but the click path here never // reaches a create/edit submission for a past date. const isLocked = !canEdit || past; // `isPulsing` is true for the single (user, date) cell // that the most recent successful mutation touched. // CSS restarts the keyframe animation whenever the // `nonce` changes (we embed it in data-pulse on the // wrapper, see above), so back-to-back mutations on the // same cell re-trigger the pulse. const isPulsing = !!pulseKey && pulseKey.userId === u.id && pulseKey.date === date; let cls: string; if (cell) { cls = isLocked ? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}` : "plan-cell"; } else { cls = isLocked ? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}` : "plan-cell plan-cell--empty"; } if (isPulsing) cls += " plan-cell--pulse"; return ( ); })} ); })}
`/`` is just a floor that `table-layout: auto` happily blows past. The first column gets a fixed width via the col element so the date stamp doesn't get stretched to share space with person columns. */}
Datum {first} {last ? ` ${last}` : ""} {shortRole(u.role_name) && ( {shortRole(u.role_name)} )}
{dayNum} {dow}
); }