Files
app/src/admin/components/PlanCellModal.tsx
BOHA f31797fb5b fix(plan): allow empty notes, use local time for past-date gate
Three related fixes from manual smoke testing:
- Zod schema: drop min(1) on note field across all four schemas — note is
  optional in real usage, and the modal initialized it to empty.
- assertNotPastDate: replace toISOString() (UTC) with local-time getters
  to match the rest of the codebase. Fixes off-by-one near day boundaries.
- createEntry / updateEntry: also check the date_to endpoint for past
  dates, so a range can't extend backwards into the past even when its
  start is today or future. Defense in depth.

Modal: drop `required` attribute and asterisk from the note field. Tests:
add 5 new cases covering empty note, past date HTTP rejection, and the
new to-endpoint check in updateEntry.
2026-06-05 13:42:21 +02:00

402 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import FormModal from "./FormModal";
import FormField from "./FormField";
import AdminDatePicker from "./AdminDatePicker";
import ConfirmModal from "./ConfirmModal";
import { ResolvedCell } from "../lib/queries/plan";
interface Project {
id: number;
name: string;
}
export type PlanCellModalMode =
| { kind: "closed" }
| { kind: "create"; userId: number; date: string }
| {
kind: "edit-range";
entryId: number;
userId: number;
date: string;
range: {
date_from: string;
date_to: string;
project_id: number | null;
category: string;
note: string;
};
}
| {
kind: "edit-override";
overrideId: number;
userId: number;
date: string;
cell: ResolvedCell;
}
| {
kind: "day-in-range";
entryId: number;
userId: number;
date: string;
range: {
date_from: string;
date_to: string;
project_id: number | null;
category: string;
note: string;
};
}
| { kind: "view"; userId: number; date: string; cell: ResolvedCell };
interface Props {
open: boolean;
mode: PlanCellModalMode;
projects: Project[];
onClose: () => void;
onSaveEntry: (body: any) => Promise<void>;
onUpdateEntry: (id: number, body: any) => Promise<void>;
onDeleteEntry: (id: number) => Promise<void>;
onSaveOverride: (body: any) => Promise<void>;
onUpdateOverride: (id: number, body: any) => Promise<void>;
onDeleteOverride: (id: number) => Promise<void>;
onCreateOverrideFromRange: (
entryId: number,
userId: number,
date: string,
) => Promise<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;
if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range")
return <DayInRangeModal {...props} mode={mode} />;
return <EditForm {...props} />;
}
function EditForm(props: Props) {
const {
mode,
onClose,
onSaveEntry,
onUpdateEntry,
onDeleteEntry,
onSaveOverride,
onUpdateOverride,
onDeleteOverride,
projects,
} = props;
const [submitting, setSubmitting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// Narrow mode to the kinds EditForm actually handles. The call site in
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
// the parameter type is the full union, so we narrow explicitly here.
if (
mode.kind !== "create" &&
mode.kind !== "edit-range" &&
mode.kind !== "edit-override"
) {
return null;
}
const isOverride = mode.kind === "edit-override";
const initial = (() => {
if (mode.kind === "create") {
return {
date_from: mode.date,
date_to: mode.date,
is_range: false,
project_id: null as number | null,
category: "work",
note: "",
};
}
if (mode.kind === "edit-range") {
return {
date_from: mode.range.date_from,
date_to: mode.range.date_to,
is_range: mode.range.date_from !== mode.range.date_to,
project_id: mode.range.project_id,
category: mode.range.category,
note: mode.range.note,
};
}
// mode.kind === "edit-override" (narrowed above)
return {
date_from: mode.date,
date_to: mode.date,
is_range: false,
project_id: mode.cell.project_id,
category: mode.cell.category,
note: mode.cell.note,
};
})();
const [dateFrom, setDateFrom] = useState(initial.date_from);
const [dateTo, setDateTo] = useState(initial.date_to);
const [isRange, setIsRange] = useState(initial.is_range);
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
const [category, setCategory] = useState(initial.category);
const [note, setNote] = useState(initial.note);
const title =
mode.kind === "create"
? "Nový záznam plánu"
: mode.kind === "edit-range"
? "Upravit rozsah"
: "Upravit přepsání dne";
const handleSubmit = async () => {
setSubmitting(true);
try {
if (mode.kind === "create") {
const userId = mode.userId;
await onSaveEntry({
user_id: userId,
date_from: dateFrom,
date_to: isRange ? dateTo : dateFrom,
project_id: projectId,
category,
note,
});
} else if (mode.kind === "edit-range") {
await onUpdateEntry(mode.entryId, {
date_from: dateFrom,
date_to: isRange ? dateTo : dateFrom,
project_id: projectId,
category,
note,
});
} else {
await onUpdateOverride(mode.overrideId, {
project_id: projectId,
category,
note,
});
}
onClose();
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
setSubmitting(true);
try {
if (mode.kind === "edit-range") {
await onDeleteEntry(mode.entryId);
} else if (mode.kind === "edit-override") {
await onDeleteOverride(mode.overrideId);
}
onClose();
} finally {
setSubmitting(false);
}
};
return (
<>
<FormModal
isOpen
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
loading={submitting}
>
{mode.kind !== "create" && (
<div
style={{
marginBottom: 12,
display: "flex",
justifyContent: "flex-end",
}}
>
<button
type="button"
className="admin-btn admin-btn-danger"
onClick={() => setConfirmDelete(true)}
disabled={submitting}
>
Smazat
</button>
</div>
)}
<FormField label="Datum od">
<AdminDatePicker
value={dateFrom}
onChange={setDateFrom}
disabled={isOverride}
/>
</FormField>
{!isOverride && (
<FormField label="Rozsah dnů">
<label>
<input
type="checkbox"
checked={isRange}
onChange={(e) => setIsRange(e.target.checked)}
/>{" "}
Více dní
</label>
</FormField>
)}
{!isOverride && isRange && (
<FormField label="Datum do">
<AdminDatePicker value={dateTo} onChange={setDateTo} />
</FormField>
)}
<FormField label="Projekt">
<select
value={projectId ?? ""}
onChange={(e) =>
setProjectId(e.target.value ? Number(e.target.value) : null)
}
>
<option value=""> bez projektu </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</FormField>
<FormField label="Kategorie">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</FormField>
<FormField label="Text poznámky (volitelný)">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={3}
/>
</FormField>
</FormModal>
<ConfirmModal
isOpen={confirmDelete}
title="Smazat záznam plánu?"
message="Tato akce je nevratná (záznam bude soft-delete)."
confirmText="Smazat"
type="danger"
confirmVariant="danger"
loading={submitting}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
/>
</>
);
}
function DayInRangeModal(
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day-in-range" }> },
) {
const { mode, onClose, onCreateOverrideFromRange } = props;
return (
<FormModal
isOpen
title={`Den ${mode.date} je součástí rozsahu`}
onClose={onClose}
hideFooter
>
<p>
Tento den je součástí rozsahu:{" "}
<strong>
{mode.range.date_from} {mode.range.date_to}
</strong>
</p>
<p>Co chcete udělat?</p>
<ul>
<li>
Upravit celý rozsah klikněte na tlačítko níže nebo zavřete a
klikněte znovu na buňku v režimu editace rozsahu.
</li>
<li>
Upravit pouze tento den vytvoří přepsání (override) pro toto datum.
</li>
<li>Zavřít ponechá rozsah beze změny.</li>
</ul>
<div
style={{
marginTop: 12,
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<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>
</div>
</FormModal>
);
}
function ViewModal(props: Props) {
const { mode, onClose } = props;
if (mode.kind !== "view") return null;
const c = mode.cell;
return (
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
<p>
<strong>Datum:</strong> {c.shift_date}
</p>
<p>
<strong>Kategorie:</strong> {c.category}
</p>
<p>
<strong>Projekt:</strong> {c.project_id ?? "—"}
</p>
<p>
<strong>Text:</strong> {c.note}
</p>
{c.rangeFrom && c.rangeTo && (
<p>
<strong>Patří do rozsahu:</strong> {c.rangeFrom} {c.rangeTo}
</p>
)}
</FormModal>
);
}