From 003807a074031268ad59b76fbe0212dbe94cc658 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 5 Jun 2026 12:57:41 +0200 Subject: [PATCH] feat(plan): grid component with sticky headers and weekend tint --- src/admin/components/PlanGrid.tsx | 86 ++++++++++++++++ src/admin/components/PlanRangeChips.tsx | 33 +++++++ src/admin/plan.css | 125 ++++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/admin/components/PlanGrid.tsx create mode 100644 src/admin/components/PlanRangeChips.tsx create mode 100644 src/admin/plan.css diff --git a/src/admin/components/PlanGrid.tsx b/src/admin/components/PlanGrid.tsx new file mode 100644 index 0000000..46483a0 --- /dev/null +++ b/src/admin/components/PlanGrid.tsx @@ -0,0 +1,86 @@ +import { GridData, ResolvedCell } from "../lib/queries/plan"; +import PlanRangeChips from "./PlanRangeChips"; + +interface Props { + data: GridData | undefined; + canEdit: boolean; + onCellClick: ( + userId: number, + date: string, + cell: ResolvedCell | null, + ) => void; +} + +const CZECH_WEEKDAYS = ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"]; + +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; +} + +export default function PlanGrid({ data, canEdit, onCellClick }: Props) { + if (!data) + return ( +
+
+
+ ); + const days = eachDay(data.date_from, data.date_to); + const users = data.users; + + return ( +
+ + + + + {users.map((u) => ( + + ))} + + + + {days.map((date) => ( + + + {users.map((u) => { + const cell = data.cells[u.id]?.[date] ?? null; + const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`; + return ( + + ); + })} + + ))} + +
Datum{u.full_name}
+ {czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}. + + +
+
+ ); +} diff --git a/src/admin/components/PlanRangeChips.tsx b/src/admin/components/PlanRangeChips.tsx new file mode 100644 index 0000000..16ec9d7 --- /dev/null +++ b/src/admin/components/PlanRangeChips.tsx @@ -0,0 +1,33 @@ +import { ResolvedCell } from "../lib/queries/plan"; + +interface Props { + cell: ResolvedCell | null; + readonly?: boolean; +} + +const CATEGORY_LABELS: Record = { + work: "Práce", + preparation: "Příprava", + travel: "Cesta / Montáž", + leave: "Dovolená", + sick: "Nemoc", + training: "Školení", + other: "Jiné", +}; + +export default function PlanRangeChips({ cell, readonly }: Props) { + if (!cell) return null; + return ( +
+ + {CATEGORY_LABELS[cell.category] ?? cell.category} + + {cell.project_id && ( + + {`#${cell.project_id}`} + + )} + {cell.note && {cell.note}} +
+ ); +} diff --git a/src/admin/plan.css b/src/admin/plan.css new file mode 100644 index 0000000..a62eace --- /dev/null +++ b/src/admin/plan.css @@ -0,0 +1,125 @@ +/* === Plán prací (work schedule) module === */ + +.plan-grid-wrap { + overflow: auto; + border: 1px solid var(--border, #e5e7eb); + border-radius: 8px; + max-height: calc(100vh - 240px); +} + +.plan-grid { + border-collapse: separate; + border-spacing: 0; + width: 100%; + font-size: 13px; +} + +.plan-grid thead th { + position: sticky; + top: 0; + background: var(--bg-secondary, #f9fafb); + z-index: 2; + padding: 8px; + border-bottom: 1px solid var(--border, #e5e7eb); + text-align: left; + font-weight: 600; + white-space: nowrap; +} + +.plan-grid thead th.plan-grid-date-col { + left: 0; + z-index: 3; + min-width: 110px; +} + +.plan-grid tbody td { + padding: 6px 8px; + border-bottom: 1px solid var(--border, #e5e7eb); + vertical-align: top; + min-width: 140px; +} + +.plan-grid tbody td.plan-grid-date-col { + position: sticky; + left: 0; + background: var(--bg-secondary, #f9fafb); + z-index: 1; + font-weight: 500; + color: var(--text-muted, #6b7280); + white-space: nowrap; +} + +.plan-grid tr.plan-grid-weekend td { + background: var(--bg-weekend, #fefce8); +} +.plan-grid tr.plan-grid-weekend td.plan-grid-date-col { + background: var(--bg-weekend-strong, #fef3c7); +} + +.plan-cell { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 1px dashed transparent; + border-radius: 6px; + padding: 4px 6px; + cursor: pointer; + min-height: 40px; +} +.plan-cell:hover { + border-color: var(--primary, #2563eb); +} +.plan-cell--readonly { + cursor: default; +} +.plan-cell--readonly:hover { + border-color: transparent; +} + +.plan-chip { + display: inline-block; + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + font-weight: 500; + color: #1f2937; + background: var(--chip-default, #e5e7eb); +} +.plan-chip--work { + background: #dbeafe; +} +.plan-chip--preparation { + background: #ccfbf1; +} +.plan-chip--travel { + background: #fde68a; +} +.plan-chip--leave { + background: #bbf7d0; +} +.plan-chip--sick { + background: #fecaca; +} +.plan-chip--training { + background: #e9d5ff; +} +.plan-chip--other { + background: #e5e7eb; +} + +.plan-cell-note { + display: block; + margin-top: 2px; + font-size: 11px; + color: var(--text-muted, #6b7280); + line-height: 1.3; +} + +.plan-toolbar { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; +}