diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index c62ce8f..aedfd34 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -247,6 +247,75 @@ describe("plan.service.resolveGrid", () => { expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`); expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`); }); + + it("stacks additive entries newest-first and caps the day at MAX_RECORDS_PER_CELL", async () => { + // Two entries cover 2099-06-12 → both appear, newest-first. A 3rd entry + // covering the same day and a 4th overlapping one push past the cap, so the + // day must still return exactly 3 (mirrors resolveCell's cap behaviour). + // + // created_at is @db.Timestamp(0) (second precision, MySQL TIMESTAMP range + // tops out at 2038), so rows created in the same second tie on the + // `created_at desc` sort. We set explicit, distinct in-range created_at + // values so the newest-first assertion is deterministic. + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-11"), + date_to: new Date("2099-06-12"), + category: "work", + note: `${N}g1`, + created_by: adminUserId, + created_at: new Date("2037-06-01T08:00:00.000Z"), + }, + }); + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-12"), + date_to: new Date("2099-06-12"), + category: "work", + note: `${N}g2`, + created_by: adminUserId, + created_at: new Date("2037-06-01T09:00:00.000Z"), // newer than g1 + }, + }); + // Two-entry day: assert length 2 and newest-first ordering. + const twoDay = await resolveGrid([adminUserId], "2099-06-11", "2099-06-12"); + expect(twoDay[adminUserId]["2099-06-12"].length).toBe(2); + expect(twoDay[adminUserId]["2099-06-12"][0].note).toBe(`${N}g2`); // newest first + // Day with no second entry stays single. + expect(twoDay[adminUserId]["2099-06-11"].length).toBe(1); + + // Add a 3rd and a 4th overlapping entry on 2099-06-12 to exceed the cap. + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-12"), + date_to: new Date("2099-06-12"), + category: "work", + note: `${N}g3`, + created_by: adminUserId, + created_at: new Date("2037-06-01T10:00:00.000Z"), + }, + }); + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-12"), + date_to: new Date("2099-06-12"), + category: "work", + note: `${N}g4`, + created_by: adminUserId, + created_at: new Date("2037-06-01T11:00:00.000Z"), + }, + }); + const cappedDay = await resolveGrid( + [adminUserId], + "2099-06-12", + "2099-06-12", + ); + expect(cappedDay[adminUserId]["2099-06-12"].length).toBe(3); + }); }); // --------------------------------------------------------------------------- diff --git a/src/admin/components/PlanCellModal.tsx b/src/admin/components/PlanCellModal.tsx index d791bf8..8dd408d 100644 --- a/src/admin/components/PlanCellModal.tsx +++ b/src/admin/components/PlanCellModal.tsx @@ -31,6 +31,7 @@ interface Project { export type PlanCellModalMode = | { kind: "closed" } | { kind: "create"; userId: number; date: string } + | { kind: "create-override"; userId: number; date: string } | { kind: "edit-range"; entryId: number; @@ -85,8 +86,14 @@ interface Props { date: string, ) => Promise; onSwitchToEditRange: (entryId: number, userId: number, date: string) => void; - /** Open the create form for a brand-new record on (userId, date). */ - onAddRecord: (userId: number, date: string) => void; + /** Open the create form for a brand-new record on (userId, date). The + * `source` picks the layer the new record lands in: "entry" in normal mode, + * "override" in exception mode (a day whose shown records are overrides). */ + onAddRecord: ( + userId: number, + date: string, + source: "entry" | "override", + ) => void; /** Open the right editor for one of the day's existing records. */ onEditRecord: (cell: ResolvedCell, userId: number, date: string) => void; } @@ -137,21 +144,26 @@ function EditForm(props: Props) { 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. + // PlanCellModal only passes "create" | "create-override" | "edit-range" | + // "edit-override" but the parameter type is the full union, so we narrow + // explicitly here. if ( mode.kind !== "create" && + mode.kind !== "create-override" && mode.kind !== "edit-range" && mode.kind !== "edit-override" ) { return null; } - const isOverride = mode.kind === "edit-override"; + // An override is a single-day record — both editing one ("edit-override") + // and creating one ("create-override") lock the date and hide the range UI. + const isOverride = + mode.kind === "edit-override" || mode.kind === "create-override"; const activeCategories = props.categories.filter((c) => c.is_active); const initial = (() => { - if (mode.kind === "create") { + if (mode.kind === "create" || mode.kind === "create-override") { return { date_from: mode.date, date_to: mode.date, @@ -196,9 +208,11 @@ function EditForm(props: Props) { const title = mode.kind === "create" ? "Nový záznam plánu" - : mode.kind === "edit-range" - ? "Upravit rozsah" - : "Upravit přepsání dne"; + : mode.kind === "create-override" + ? "Nové přepsání dne" + : mode.kind === "edit-range" + ? "Upravit rozsah" + : "Upravit přepsání dne"; const handleSubmit = async () => { setSubmitting(true); @@ -213,6 +227,17 @@ function EditForm(props: Props) { category, note, }); + } else if (mode.kind === "create-override") { + // An override is a single day — lock it to mode.date regardless of + // any local dateFrom/isRange state (the date field is disabled for + // overrides, but be explicit). + await onSaveOverride({ + user_id: mode.userId, + shift_date: mode.date, + project_id: projectId, + category, + note, + }); } else if (mode.kind === "edit-range") { await onUpdateEntry(mode.entryId, { date_from: dateFrom, @@ -255,6 +280,10 @@ function EditForm(props: Props) { const showRetiredCategory = category && !activeCategories.some((c) => c.key === category); + // Both create flows ("create" entry, "create-override") show "Vytvořit" and + // have no Smazat button (there is no existing row to delete yet). + const isCreate = mode.kind === "create" || mode.kind === "create-override"; + return ( <> - {mode.kind !== "create" && ( + {!isCreate && ( diff --git a/src/admin/hooks/usePlanWork.ts b/src/admin/hooks/usePlanWork.ts index 11bfab8..5bb2a35 100644 --- a/src/admin/hooks/usePlanWork.ts +++ b/src/admin/hooks/usePlanWork.ts @@ -12,6 +12,7 @@ export type ViewMode = "week" | "month"; export type ModalMode = | { kind: "closed" } | { kind: "create"; userId: number; date: string } + | { kind: "create-override"; userId: number; date: string } | { kind: "edit-range"; entryId: number; userId: number; date: string } | { kind: "edit-override"; overrideId: number; userId: number; date: string } | { kind: "day-in-range"; userId: number; date: string; entryId: number } diff --git a/src/admin/pages/PlanWork.tsx b/src/admin/pages/PlanWork.tsx index f22055f..590de21 100644 --- a/src/admin/pages/PlanWork.tsx +++ b/src/admin/pages/PlanWork.tsx @@ -198,10 +198,19 @@ export default function PlanWork() { // Null when the modal is closed or in a "create" state with no source cell. const [modalCell, setModalCell] = useState>(null); + // Open the create form for a brand-new record. `source` picks the layer: + // "entry" (default) opens the normal create form; "override" opens the + // single-day create-override form. The day panel passes "override" when the + // day is in exception mode so the new record lands in the same layer that's + // showing (otherwise resolveCell would hide a new entry behind the override). const openCreate = useCallback( - (userId: number, date: string) => { + (userId: number, date: string, source: "entry" | "override" = "entry") => { setModalCell(null); - setHookModal({ kind: "create", userId, date }); + if (source === "override") { + setHookModal({ kind: "create-override", userId, date }); + } else { + setHookModal({ kind: "create", userId, date }); + } }, [setHookModal], ); @@ -278,11 +287,14 @@ export default function PlanWork() { // Switch the modal from "day-in-range" to "edit-range" mode, keeping the // same cell context so the EditForm opens with the range's values. + // Uses `modalCell` — the record the user actually clicked in the day panel + // (captured by editRecord) — NOT getCell (which returns the PRIMARY record). + // On a day with 2+ records these differ; using the primary would edit the + // wrong range. modalCell is already set to the clicked record at this point + // (editRecord → day-in-range → here). const switchToEditRange = useCallback( (entryId: number, userId: number, date: string) => { - const cell = getCell(grid, userId, date); - if (!cell) return; - setModalCell(cell); + if (!modalCell) return; setHookModal({ kind: "edit-range", entryId, @@ -290,7 +302,7 @@ export default function PlanWork() { date, }); }, - [grid, setHookModal], + [modalCell, setHookModal], ); // --- Mutation wrappers ----------------------------------------------------- @@ -457,11 +469,14 @@ export default function PlanWork() { ); // "Create override from range" — turn a multi-day entry into a one-day - // override for the clicked date. We look up the cell's source entry and - // create a new override mirroring its fields, locked to a single date. + // override for the clicked date. We mirror the CLICKED record's fields + // (modalCell, captured by editRecord) — NOT getCell, which returns the + // PRIMARY record. On a day with 2+ records the clicked entry can differ + // from the primary; carving from the primary would copy the wrong + // project/category/note into the override. const createOverrideFromRange = useCallback( async (entryId: number, userId: number, date: string) => { - const cell = getCell(grid, userId, date); + const cell = modalCell; if (!cell) { alert.error("Nepodařilo se najít zdrojový záznam"); return; @@ -485,7 +500,7 @@ export default function PlanWork() { // symmetry with the modal contract. void entryId; }, - [grid, createOverride, alert, firePulse, rollbackMutation], + [modalCell, createOverride, alert, firePulse, rollbackMutation], ); // --- Modal key for remount-on-mode-change ---------------------------------- @@ -506,6 +521,9 @@ export default function PlanWork() { const modalMode: PlanCellModalMode = useMemo(() => { if (hookModal.kind === "closed") return { kind: "closed" }; if (hookModal.kind === "create") return hookModal; + // create-override carries the same userId/date payload as create and needs + // no merged cell/range — pass it straight through (EditForm handles it). + if (hookModal.kind === "create-override") return hookModal; if (hookModal.kind === "view") { if (!modalCell) return { kind: "closed" }; return {