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:
@@ -247,6 +247,75 @@ describe("plan.service.resolveGrid", () => {
|
|||||||
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
|
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
|
||||||
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface Project {
|
|||||||
export type PlanCellModalMode =
|
export type PlanCellModalMode =
|
||||||
| { kind: "closed" }
|
| { kind: "closed" }
|
||||||
| { kind: "create"; userId: number; date: string }
|
| { kind: "create"; userId: number; date: string }
|
||||||
|
| { kind: "create-override"; userId: number; date: string }
|
||||||
| {
|
| {
|
||||||
kind: "edit-range";
|
kind: "edit-range";
|
||||||
entryId: number;
|
entryId: number;
|
||||||
@@ -85,8 +86,14 @@ interface Props {
|
|||||||
date: string,
|
date: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||||||
/** Open the create form for a brand-new record on (userId, date). */
|
/** Open the create form for a brand-new record on (userId, date). The
|
||||||
onAddRecord: (userId: number, date: string) => void;
|
* `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. */
|
/** Open the right editor for one of the day's existing records. */
|
||||||
onEditRecord: (cell: ResolvedCell, userId: number, date: string) => void;
|
onEditRecord: (cell: ResolvedCell, userId: number, date: string) => void;
|
||||||
}
|
}
|
||||||
@@ -137,21 +144,26 @@ function EditForm(props: Props) {
|
|||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||||||
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
|
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
|
||||||
// the parameter type is the full union, so we narrow explicitly here.
|
// "edit-override" but the parameter type is the full union, so we narrow
|
||||||
|
// explicitly here.
|
||||||
if (
|
if (
|
||||||
mode.kind !== "create" &&
|
mode.kind !== "create" &&
|
||||||
|
mode.kind !== "create-override" &&
|
||||||
mode.kind !== "edit-range" &&
|
mode.kind !== "edit-range" &&
|
||||||
mode.kind !== "edit-override"
|
mode.kind !== "edit-override"
|
||||||
) {
|
) {
|
||||||
return null;
|
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 activeCategories = props.categories.filter((c) => c.is_active);
|
||||||
|
|
||||||
const initial = (() => {
|
const initial = (() => {
|
||||||
if (mode.kind === "create") {
|
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||||
return {
|
return {
|
||||||
date_from: mode.date,
|
date_from: mode.date,
|
||||||
date_to: mode.date,
|
date_to: mode.date,
|
||||||
@@ -196,9 +208,11 @@ function EditForm(props: Props) {
|
|||||||
const title =
|
const title =
|
||||||
mode.kind === "create"
|
mode.kind === "create"
|
||||||
? "Nový záznam plánu"
|
? "Nový záznam plánu"
|
||||||
: mode.kind === "edit-range"
|
: mode.kind === "create-override"
|
||||||
? "Upravit rozsah"
|
? "Nové přepsání dne"
|
||||||
: "Upravit přepsání dne";
|
: mode.kind === "edit-range"
|
||||||
|
? "Upravit rozsah"
|
||||||
|
: "Upravit přepsání dne";
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -213,6 +227,17 @@ function EditForm(props: Props) {
|
|||||||
category,
|
category,
|
||||||
note,
|
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") {
|
} else if (mode.kind === "edit-range") {
|
||||||
await onUpdateEntry(mode.entryId, {
|
await onUpdateEntry(mode.entryId, {
|
||||||
date_from: dateFrom,
|
date_from: dateFrom,
|
||||||
@@ -255,6 +280,10 @@ function EditForm(props: Props) {
|
|||||||
const showRetiredCategory =
|
const showRetiredCategory =
|
||||||
category && !activeCategories.some((c) => c.key === category);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -262,10 +291,10 @@ function EditForm(props: Props) {
|
|||||||
title={title}
|
title={title}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
submitText={isCreate ? "Vytvořit" : "Uložit"}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
>
|
>
|
||||||
{mode.kind !== "create" && (
|
{!isCreate && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -537,6 +566,12 @@ function DayPanel(
|
|||||||
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
|
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
|
||||||
const atCap = mode.cells.length >= 3;
|
const atCap = mode.cells.length >= 3;
|
||||||
const free = 3 - mode.cells.length;
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
@@ -601,7 +636,13 @@ function DayPanel(
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={atCap}
|
disabled={atCap}
|
||||||
onClick={() => onAddRecord(mode.userId, mode.date)}
|
onClick={() =>
|
||||||
|
onAddRecord(
|
||||||
|
mode.userId,
|
||||||
|
mode.date,
|
||||||
|
isOverrideMode ? "override" : "entry",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ Přidat záznam
|
+ Přidat záznam
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ViewMode = "week" | "month";
|
|||||||
export type ModalMode =
|
export type ModalMode =
|
||||||
| { kind: "closed" }
|
| { kind: "closed" }
|
||||||
| { kind: "create"; userId: number; date: string }
|
| { kind: "create"; userId: number; date: string }
|
||||||
|
| { kind: "create-override"; userId: number; date: string }
|
||||||
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
||||||
| { kind: "edit-override"; overrideId: number; userId: number; date: string }
|
| { kind: "edit-override"; overrideId: number; userId: number; date: string }
|
||||||
| { kind: "day-in-range"; userId: number; date: string; entryId: number }
|
| { kind: "day-in-range"; userId: number; date: string; entryId: number }
|
||||||
|
|||||||
@@ -198,10 +198,19 @@ export default function PlanWork() {
|
|||||||
// Null when the modal is closed or in a "create" state with no source cell.
|
// Null when the modal is closed or in a "create" state with no source cell.
|
||||||
const [modalCell, setModalCell] = useState<ReturnType<typeof getCell>>(null);
|
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(
|
const openCreate = useCallback(
|
||||||
(userId: number, date: string) => {
|
(userId: number, date: string, source: "entry" | "override" = "entry") => {
|
||||||
setModalCell(null);
|
setModalCell(null);
|
||||||
setHookModal({ kind: "create", userId, date });
|
if (source === "override") {
|
||||||
|
setHookModal({ kind: "create-override", userId, date });
|
||||||
|
} else {
|
||||||
|
setHookModal({ kind: "create", userId, date });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setHookModal],
|
[setHookModal],
|
||||||
);
|
);
|
||||||
@@ -278,11 +287,14 @@ export default function PlanWork() {
|
|||||||
|
|
||||||
// Switch the modal from "day-in-range" to "edit-range" mode, keeping the
|
// 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.
|
// 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(
|
const switchToEditRange = useCallback(
|
||||||
(entryId: number, userId: number, date: string) => {
|
(entryId: number, userId: number, date: string) => {
|
||||||
const cell = getCell(grid, userId, date);
|
if (!modalCell) return;
|
||||||
if (!cell) return;
|
|
||||||
setModalCell(cell);
|
|
||||||
setHookModal({
|
setHookModal({
|
||||||
kind: "edit-range",
|
kind: "edit-range",
|
||||||
entryId,
|
entryId,
|
||||||
@@ -290,7 +302,7 @@ export default function PlanWork() {
|
|||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[grid, setHookModal],
|
[modalCell, setHookModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Mutation wrappers -----------------------------------------------------
|
// --- Mutation wrappers -----------------------------------------------------
|
||||||
@@ -457,11 +469,14 @@ export default function PlanWork() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// "Create override from range" — turn a multi-day entry into a one-day
|
// "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
|
// override for the clicked date. We mirror the CLICKED record's fields
|
||||||
// create a new override mirroring its fields, locked to a single date.
|
// (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(
|
const createOverrideFromRange = useCallback(
|
||||||
async (entryId: number, userId: number, date: string) => {
|
async (entryId: number, userId: number, date: string) => {
|
||||||
const cell = getCell(grid, userId, date);
|
const cell = modalCell;
|
||||||
if (!cell) {
|
if (!cell) {
|
||||||
alert.error("Nepodařilo se najít zdrojový záznam");
|
alert.error("Nepodařilo se najít zdrojový záznam");
|
||||||
return;
|
return;
|
||||||
@@ -485,7 +500,7 @@ export default function PlanWork() {
|
|||||||
// symmetry with the modal contract.
|
// symmetry with the modal contract.
|
||||||
void entryId;
|
void entryId;
|
||||||
},
|
},
|
||||||
[grid, createOverride, alert, firePulse, rollbackMutation],
|
[modalCell, createOverride, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Modal key for remount-on-mode-change ----------------------------------
|
// --- Modal key for remount-on-mode-change ----------------------------------
|
||||||
@@ -506,6 +521,9 @@ export default function PlanWork() {
|
|||||||
const modalMode: PlanCellModalMode = useMemo(() => {
|
const modalMode: PlanCellModalMode = useMemo(() => {
|
||||||
if (hookModal.kind === "closed") return { kind: "closed" };
|
if (hookModal.kind === "closed") return { kind: "closed" };
|
||||||
if (hookModal.kind === "create") return hookModal;
|
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 (hookModal.kind === "view") {
|
||||||
if (!modalCell) return { kind: "closed" };
|
if (!modalCell) return { kind: "closed" };
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user