fix(plan): readable audit descriptions, project labels, dashboard invalidation, view-only & sticky-column UI
Work-plan fixes from this session: - Czech audit descriptions for plan entries/overrides (was empty '-') - server-embedded project label on grid cells (was '-' past the 100-project cap) - dashboard activity + audit-log cache invalidation on plan mutations - read-only cells: no '+' on empty, keep hover on cells with a record - view modal: Czech category + formatted dates - sticky date column made opaque (no bleed-through on horizontal scroll) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import FormField from "./FormField";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import { ResolvedCell } from "../lib/queries/plan";
|
||||
import useReducedMotion from "../hooks/useReducedMotion";
|
||||
import {
|
||||
ResolvedCell,
|
||||
PLAN_CATEGORIES,
|
||||
planCategoryLabel,
|
||||
cellProjectLabel,
|
||||
} from "../lib/queries/plan";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
@@ -68,16 +76,6 @@ interface Props {
|
||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "preparation", label: "Příprava" },
|
||||
{ value: "travel", label: "Cesta / Montáž" },
|
||||
{ value: "leave", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "training", label: "Školení" },
|
||||
{ value: "other", label: "Jiné" },
|
||||
];
|
||||
|
||||
export default function PlanCellModal(props: Props) {
|
||||
const { open, mode, onClose } = props;
|
||||
if (!open || mode.kind === "closed") return null;
|
||||
@@ -218,19 +216,36 @@ function EditForm(props: Props) {
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
loading={submitting}
|
||||
>
|
||||
{mode.kind !== "create" && (
|
||||
<div className="plan-modal-actions">
|
||||
footerLeft={
|
||||
mode.kind !== "create" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
Smazat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FormField label="Datum od">
|
||||
<AdminDatePicker
|
||||
value={dateFrom}
|
||||
@@ -240,13 +255,13 @@ function EditForm(props: Props) {
|
||||
</FormField>
|
||||
{!isOverride && (
|
||||
<FormField label="Rozsah dnů">
|
||||
<label>
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRange}
|
||||
onChange={(e) => setIsRange(e.target.checked)}
|
||||
/>{" "}
|
||||
Více dní
|
||||
/>
|
||||
<span>Více dní</span>
|
||||
</label>
|
||||
</FormField>
|
||||
)}
|
||||
@@ -257,6 +272,7 @@ function EditForm(props: Props) {
|
||||
)}
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={projectId ?? ""}
|
||||
onChange={(e) =>
|
||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||
@@ -272,10 +288,11 @@ function EditForm(props: Props) {
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
{PLAN_CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
@@ -284,6 +301,7 @@ function EditForm(props: Props) {
|
||||
</FormField>
|
||||
<FormField label="Text poznámky (volitelný)">
|
||||
<textarea
|
||||
className="admin-form-textarea"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
@@ -312,62 +330,88 @@ function DayInRangeModal(
|
||||
) {
|
||||
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
|
||||
props;
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
// Two clear paths: edit the whole range (default, safe) or carve out
|
||||
// a one-day override (deviates from the plan). Close = no action.
|
||||
const choices = [
|
||||
{
|
||||
key: "edit-range",
|
||||
title: "Upravit celý rozsah",
|
||||
desc: `Změní projekt, kategorii nebo poznámku pro všechny dny v rozsahu ${mode.range.date_from} – ${mode.range.date_to}.`,
|
||||
tone: "primary" as const,
|
||||
onClick: () => onSwitchToEditRange(mode.entryId, mode.userId, mode.date),
|
||||
cta: "Upravit rozsah",
|
||||
},
|
||||
{
|
||||
key: "override-day",
|
||||
title: "Upravit pouze tento den",
|
||||
desc: `Vytvoří přepsání pro ${mode.range.date_from === mode.date ? "tento" : mode.date}; zbytek rozsahu zůstane beze změny.`,
|
||||
tone: "secondary" as const,
|
||||
onClick: async () => {
|
||||
await onCreateOverrideFromRange(mode.entryId, mode.userId, mode.date);
|
||||
onClose();
|
||||
},
|
||||
cta: "Vytvořit přepsání",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isOpen
|
||||
title={`Den ${mode.date} je součástí rozsahu`}
|
||||
title="Den je součástí rozsahu"
|
||||
onClose={onClose}
|
||||
hideFooter
|
||||
size="md"
|
||||
>
|
||||
<p>
|
||||
Tento den je součástí rozsahu:{" "}
|
||||
<p className="plan-modal-intro">
|
||||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||
<strong>
|
||||
{mode.range.date_from} – {mode.range.date_to}
|
||||
</strong>
|
||||
. Vyberte, co chcete udělat:
|
||||
</p>
|
||||
<p>Co chcete udělat?</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Upravit celý rozsah</strong> — otevře editor celého rozsahu.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Upravit pouze tento den</strong> — vytvoří přepsání (override)
|
||||
pro toto datum; zbytek rozsahu zůstane beze změny.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zavřít</strong> — ponechá rozsah beze změny.
|
||||
</li>
|
||||
<ul className="plan-choices">
|
||||
<AnimatePresence>
|
||||
{choices.map((c, i) => (
|
||||
<motion.li
|
||||
key={c.key}
|
||||
className={`plan-choice plan-choice-${c.tone}`}
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.22,
|
||||
delay: reducedMotion ? 0 : 0.04 + i * 0.05,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="plan-choice-body">
|
||||
<div className="plan-choice-title">{c.title}</div>
|
||||
<div className="plan-choice-desc">{c.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
c.tone === "primary"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</button>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
<div className="plan-modal-actions">
|
||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zavřít
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={async () => {
|
||||
await onCreateOverrideFromRange(
|
||||
mode.entryId,
|
||||
mode.userId,
|
||||
mode.date,
|
||||
);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Upravit pouze tento den
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() =>
|
||||
onSwitchToEditRange(mode.entryId, mode.userId, mode.date)
|
||||
}
|
||||
>
|
||||
Upravit celý rozsah
|
||||
Zavřít — ponechat rozsah beze změny
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -378,31 +422,36 @@ function ViewModal(props: Props) {
|
||||
const { mode, onClose, projects } = props;
|
||||
if (mode.kind !== "view") return null;
|
||||
const c = mode.cell;
|
||||
const project = c.project_id
|
||||
// Prefer the server-embedded project label; fall back to the projects-list
|
||||
// lookup only for cells that predate the embed (stale cache).
|
||||
const fallback = c.project_id
|
||||
? (projects.find((p) => p.id === c.project_id) ?? null)
|
||||
: null;
|
||||
const projectLabel =
|
||||
cellProjectLabel(c) ??
|
||||
(fallback
|
||||
? fallback.project_number
|
||||
? `${fallback.project_number} — ${fallback.name}`
|
||||
: fallback.name
|
||||
: null);
|
||||
return (
|
||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||||
<p>
|
||||
<strong>Datum:</strong> {c.shift_date}
|
||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Kategorie:</strong> {c.category}
|
||||
<strong>Kategorie:</strong> {planCategoryLabel(c.category)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Projekt:</strong>{" "}
|
||||
{project
|
||||
? project.project_number
|
||||
? `${project.project_number} — ${project.name}`
|
||||
: project.name
|
||||
: "—"}
|
||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Text:</strong> {c.note || "—"}
|
||||
</p>
|
||||
{c.rangeFrom && c.rangeTo && (
|
||||
<p>
|
||||
<strong>Patří do rozsahu:</strong> {c.rangeFrom} – {c.rangeTo}
|
||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||
{formatDate(c.rangeTo)}
|
||||
</p>
|
||||
)}
|
||||
</FormModal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GridData, ResolvedCell } from "../lib/queries/plan";
|
||||
import { useMemo } from "react";
|
||||
import { GridData, ResolvedCell, planCategoryLabel } from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
|
||||
@@ -6,6 +7,11 @@ interface Props {
|
||||
data: GridData | undefined;
|
||||
canEdit: boolean;
|
||||
projects: Project[];
|
||||
// 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,
|
||||
@@ -13,7 +19,19 @@ interface Props {
|
||||
) => void;
|
||||
}
|
||||
|
||||
const CZECH_WEEKDAYS = ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"];
|
||||
// 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[] = [];
|
||||
@@ -34,12 +52,51 @@ function isWeekend(dateStr: string): boolean {
|
||||
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,
|
||||
pulseKey,
|
||||
onCellClick,
|
||||
}: Props) {
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
@@ -48,53 +105,134 @@ export default function PlanGrid({
|
||||
);
|
||||
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 (
|
||||
<div className="plan-grid-wrap">
|
||||
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
table — `min-width` on `<th>`/`<td>` 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. */}
|
||||
<colgroup>
|
||||
<col className="plan-grid-date-col" />
|
||||
{users.map((u) => (
|
||||
<col key={u.id} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="plan-grid-date-col">Datum</th>
|
||||
{users.map((u) => (
|
||||
<th key={u.id}>{u.full_name}</th>
|
||||
))}
|
||||
{users.map((u) => {
|
||||
const { first, last } = splitName(u.full_name);
|
||||
return (
|
||||
<th key={u.id}>
|
||||
<span className="plan-person-head">
|
||||
<span className="plan-person-dot" aria-hidden />
|
||||
<span className="plan-person-name">
|
||||
<strong title={u.full_name}>
|
||||
{first}
|
||||
{last ? ` ${last}` : ""}
|
||||
</strong>
|
||||
{shortRole(u.role_name) && (
|
||||
<small>{shortRole(u.role_name)}</small>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((date) => (
|
||||
<tr
|
||||
key={date}
|
||||
className={isWeekend(date) ? "plan-grid-weekend" : ""}
|
||||
>
|
||||
<td className="plan-grid-date-col">
|
||||
{czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}.
|
||||
</td>
|
||||
{users.map((u) => {
|
||||
const cell = data.cells[u.id]?.[date] ?? null;
|
||||
const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`;
|
||||
return (
|
||||
<td key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
onClick={() => onCellClick(u.id, date, cell)}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={cell}
|
||||
project={
|
||||
cell?.project_id
|
||||
? (projects.find((p) => p.id === cell.project_id) ??
|
||||
null)
|
||||
: null
|
||||
{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 (
|
||||
<tr key={date} className={trCls}>
|
||||
<td className="plan-grid-date-col">
|
||||
<span className="plan-date-stamp">
|
||||
<span className="plan-date-daynum">{dayNum}</span>
|
||||
<span className="plan-date-dow">{dow}</span>
|
||||
</span>
|
||||
</td>
|
||||
{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 (
|
||||
<td key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
onClick={() => onCellClick(u.id, date, cell)}
|
||||
aria-label={
|
||||
cell
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category)}`
|
||||
: isLocked
|
||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||
}
|
||||
readonly={!canEdit}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={cell}
|
||||
project={
|
||||
cell?.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === cell.project_id,
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ResolvedCell } from "../lib/queries/plan";
|
||||
import {
|
||||
ResolvedCell,
|
||||
planCategoryLabel,
|
||||
cellProjectLabel,
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
|
||||
interface Props {
|
||||
@@ -7,34 +11,24 @@ interface Props {
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
work: "Práce",
|
||||
preparation: "Příprava",
|
||||
travel: "Cesta / Montáž",
|
||||
leave: "Dovolená",
|
||||
sick: "Nemoc",
|
||||
training: "Školení",
|
||||
other: "Jiné",
|
||||
};
|
||||
|
||||
export default function PlanRangeChips({ cell, project, readonly }: Props) {
|
||||
void readonly;
|
||||
if (!cell) return null;
|
||||
const projectLabel = project
|
||||
// Prefer the server-embedded project label (always present). Fall back to
|
||||
// the looked-up `project` prop only if the cell predates the embed (stale
|
||||
// cache).
|
||||
const fallbackLabel = project
|
||||
? project.project_number
|
||||
? `${project.project_number} — ${project.name}`
|
||||
: project.name
|
||||
: null;
|
||||
const projectTitle = project
|
||||
? project.project_number
|
||||
? `${project.project_number} — ${project.name}`
|
||||
: project.name
|
||||
: undefined;
|
||||
const projectLabel = cellProjectLabel(cell) ?? fallbackLabel;
|
||||
const projectTitle = projectLabel ?? undefined;
|
||||
return (
|
||||
<div className="plan-chip-block">
|
||||
<div className="plan-chip-line">
|
||||
<span className={`plan-chip plan-chip--${cell.category}`}>
|
||||
{CATEGORY_LABELS[cell.category] ?? cell.category}
|
||||
{planCategoryLabel(cell.category)}
|
||||
</span>
|
||||
{projectLabel && (
|
||||
<span className="plan-chip-project" title={projectTitle}>
|
||||
|
||||
Reference in New Issue
Block a user