fix(plan): edit/add the clicked record + layer-aware add in exception mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 11:29:52 +02:00
parent 9370423fb0
commit 23ac924472
4 changed files with 151 additions and 22 deletions

View File

@@ -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);
});
});
// ---------------------------------------------------------------------------

View File

@@ -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<void>;
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 (
<>
<Modal
@@ -262,10 +291,10 @@ function EditForm(props: Props) {
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
submitText={isCreate ? "Vytvořit" : "Uložit"}
loading={submitting}
>
{mode.kind !== "create" && (
{!isCreate && (
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
@@ -537,6 +566,12 @@ function DayPanel(
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
const atCap = mode.cells.length >= 3;
const free = 3 - mode.cells.length;
// A day is in "exception mode" when its shown records are overrides. Cells
// are single-layer (all overrides OR all entries), so cells[0] is enough.
// "+ Přidat záznam" must add to the layer that's showing — an override here,
// otherwise resolveCell would hide a freshly-created entry behind the
// existing override after refetch.
const isOverrideMode = mode.cells[0]?.source === "override";
return (
<Modal
isOpen
@@ -601,7 +636,13 @@ function DayPanel(
variant="contained"
color="primary"
disabled={atCap}
onClick={() => onAddRecord(mode.userId, mode.date)}
onClick={() =>
onAddRecord(
mode.userId,
mode.date,
isOverrideMode ? "override" : "entry",
)
}
>
+ Přidat záznam
</Button>

View File

@@ -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 }

View File

@@ -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<ReturnType<typeof getCell>>(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 {