diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index db71e34..c62ce8f 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -89,10 +89,9 @@ afterAll(async () => { // --------------------------------------------------------------------------- describe("plan.service.resolveCell", () => { - it("returns null when nothing covers the date", async () => { - // No entry, no override for (adminUserId, "2099-01-01") + it("returns [] when nothing covers the date", async () => { const result = await resolveCell(adminUserId, "2099-01-01"); - expect(result).toBeNull(); + expect(result).toEqual([]); }); it("returns the entry that covers the date", async () => { @@ -107,17 +106,14 @@ describe("plan.service.resolveCell", () => { }, }); const result = await resolveCell(adminUserId, "2099-06-05"); - expect(result).not.toBeNull(); - expect(result?.source).toBe("entry"); - expect(result?.note).toBe(`${N}PLC upgrade`); - expect(result?.category).toBe("work"); - expect(result?.rangeFrom).toBe("2099-06-01"); - expect(result?.rangeTo).toBe("2099-06-10"); + expect(result.length).toBe(1); + expect(result[0].source).toBe("entry"); + expect(result[0].note).toBe(`${N}PLC upgrade`); + expect(result[0].rangeFrom).toBe("2099-06-01"); + expect(result[0].rangeTo).toBe("2099-06-10"); }); - it("returns the override for that day, not the entry", async () => { - // An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05 - // takes precedence. + it("returns overrides (entries hidden) when an override exists", async () => { await prisma.work_plan_entries.create({ data: { user_id: adminUserId, @@ -138,10 +134,51 @@ describe("plan.service.resolveCell", () => { }, }); const result = await resolveCell(adminUserId, "2099-06-05"); - expect(result).not.toBeNull(); - expect(result?.source).toBe("override"); - expect(result?.note).toBe(`${N}Volno po noční`); - expect(result?.category).toBe("leave"); + expect(result.length).toBe(1); + expect(result[0].source).toBe("override"); + expect(result[0].note).toBe(`${N}Volno po noční`); + }); + + it("returns multiple additive entries newest-first", async () => { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-01"), + date_to: new Date("2099-06-10"), + category: "work", + note: `${N}first`, + created_by: adminUserId, + }, + }); + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-05"), + date_to: new Date("2099-06-05"), + category: "work", + note: `${N}second`, + created_by: adminUserId, + }, + }); + const result = await resolveCell(adminUserId, "2099-06-05"); + expect(result.length).toBe(2); + expect(result[0].note).toBe(`${N}second`); // newest first + }); + + it("caps the returned records at MAX_RECORDS_PER_CELL", async () => { + for (let i = 0; i < 4; i++) { + await prisma.work_plan_overrides.create({ + data: { + user_id: adminUserId, + shift_date: new Date("2099-06-06"), + category: "leave", + note: `${N}cap${i}`, + created_by: adminUserId, + }, + }); + } + const result = await resolveCell(adminUserId, "2099-06-06"); + expect(result.length).toBe(3); }); it("ignores soft-deleted entries and overrides", async () => { @@ -157,7 +194,7 @@ describe("plan.service.resolveCell", () => { }, }); const result = await resolveCell(adminUserId, "2099-06-05"); - expect(result).toBeNull(); + expect(result).toEqual([]); }); }); @@ -178,11 +215,11 @@ describe("plan.service.resolveGrid", () => { }, }); const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05"); - expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`); - expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`); - expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`); - expect(cells[adminUserId]["2099-06-04"]).toBeNull(); - expect(cells[adminUserId]["2099-06-05"]).toBeNull(); + expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-04"]).toEqual([]); + expect(cells[adminUserId]["2099-06-05"]).toEqual([]); }); it("applies override on a covered day", async () => { @@ -206,9 +243,9 @@ describe("plan.service.resolveGrid", () => { }, }); const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03"); - expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`); - expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`); - expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`); + expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`); + expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`); }); }); @@ -857,7 +894,7 @@ describe("HTTP /api/admin/plan", () => { const body = res.json(); expect(body.data.users).toBeDefined(); expect(body.data.cells[adminUserId]).toBeDefined(); - expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe( + expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe( `${N}grid test`, ); }); diff --git a/src/admin/components/PlanGrid.tsx b/src/admin/components/PlanGrid.tsx index fe55e4a..5f4e862 100644 --- a/src/admin/components/PlanGrid.tsx +++ b/src/admin/components/PlanGrid.tsx @@ -529,7 +529,8 @@ export default function PlanGrid({ {users.map((u) => { - const cell = data.cells[u.id]?.[date] ?? null; + const cellArr = data.cells[u.id]?.[date] ?? []; + const cell = cellArr[0] ?? 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), diff --git a/src/admin/components/dashboard/DashTodayPlan.tsx b/src/admin/components/dashboard/DashTodayPlan.tsx index da0a464..4285a67 100644 --- a/src/admin/components/dashboard/DashTodayPlan.tsx +++ b/src/admin/components/dashboard/DashTodayPlan.tsx @@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography"; import { Card } from "../../ui"; import { formatDate } from "../../utils/formatters"; -/** The logged-in user's resolved work plan for today (see GET /api/admin/dashboard). */ +/** One resolved plan record for today (see GET /api/admin/dashboard). */ export interface TodayPlan { shift_date: string; category: string; @@ -23,15 +23,64 @@ function projectLabel(p: TodayPlan): string { return parts.length > 0 ? parts.join(" — ") : "Bez projektu"; } -/** - * "Vaše dnešní zařazení" — shows the logged-in user's work-plan entry for today - * (category + project/site + note, with a range badge when the day is part of a - * multi-day range). `plan === null` = the user has plan access but nothing is - * scheduled today (muted empty state). Rendered near the clock-in on the - * dashboard; the caller gates it on the plan permission. - */ -export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) { - const color = plan?.category_color || "var(--mui-palette-primary-main)"; +function PlanRow({ plan }: { plan: TodayPlan }) { + const color = plan.category_color || "var(--mui-palette-primary-main)"; + return ( + + + + + + {plan.category_label} + + + {plan.rangeFrom && plan.rangeTo && plan.rangeFrom !== plan.rangeTo && ( + + součást rozsahu {formatDate(plan.rangeFrom)} –{" "} + {formatDate(plan.rangeTo)} + + )} + + + {projectLabel(plan)} + + {plan.note && ( + + {plan.note} + + )} + + ); +} + +/** "Vaše dnešní zařazení" — the logged-in user's resolved plan record(s) for + * today (up to 3). Empty array = nothing scheduled. */ +export default function DashTodayPlan({ plans }: { plans: TodayPlan[] }) { return ( Vaše dnešní zařazení - - {plan ? ( - - + {plans.length > 0 ? ( + + {plans.map((p, i) => ( - - - {plan.category_label} - + - {plan.rangeFrom && - plan.rangeTo && - plan.rangeFrom !== plan.rangeTo && ( - - součást rozsahu {formatDate(plan.rangeFrom)} –{" "} - {formatDate(plan.rangeTo)} - - )} - - - - {projectLabel(plan)} - - - {plan.note && ( - - {plan.note} - - )} + ))} ) : ( diff --git a/src/admin/hooks/usePlanWork.ts b/src/admin/hooks/usePlanWork.ts index 659c4a0..65af092 100644 --- a/src/admin/hooks/usePlanWork.ts +++ b/src/admin/hooks/usePlanWork.ts @@ -150,16 +150,16 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { key: readonly unknown[], userId: number, dates: string[], - mutator: (prev: ResolvedCell | null) => ResolvedCell | null, - ): Record | null { + mutator: (prev: ResolvedCell[]) => ResolvedCell[], + ): Record | null { const prev = snapshotGrid(key); if (!prev) return null; const userPrev = prev.cells[userId] ?? {}; - const rolled: Record = {}; - const userNext: Record = { ...userPrev }; + const rolled: Record = {}; + const userNext: Record = { ...userPrev }; for (const date of dates) { - rolled[date] = userPrev[date] ?? null; - userNext[date] = mutator(rolled[date]); + rolled[date] = userPrev[date] ?? []; + userNext[date] = mutator(rolled[date]).slice(0, 3); } qc.setQueryData(key, { ...prev, @@ -244,7 +244,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { // the ResolvedCell mirrors the request body. const days = eachDay(body.date_from, body.date_to); const id = data && typeof data.id === "number" ? data.id : null; - const rolled = patchCells(currentGridKey, body.user_id, days, () => + const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [ makeEntryCell({ userId: body.user_id, date: days[0], @@ -255,7 +255,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { rangeTo: body.date_to, entryId: id, }), - ); + ...prev, + ]); // Stash the rollback on the mutation object so PlanWork can call // it from onError. (createEntry as any)._rolled = rolled; @@ -294,8 +295,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { let ownerUserId: number | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const cell of Object.values(byDate)) { - if (cell && cell.entryId === id) { + for (const cells of Object.values(byDate)) { + if (cells.some((c) => c.entryId === id)) { ownerUserId = Number(uidStr); break; } @@ -304,20 +305,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { } } if (ownerUserId !== null) { - const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) => - makeEntryCell({ - userId: ownerUserId!, - date: days[0], - projectId: - body.project_id === undefined - ? (prev?.project_id ?? null) - : body.project_id, - category: body.category ?? prev?.category ?? "work", - note: body.note ?? prev?.note ?? "", - rangeFrom: body.date_from, - rangeTo: body.date_to, - entryId: id, - }), + const rolled = patchCells( + currentGridKey, + ownerUserId, + days, + (prev) => { + const existing = prev.find((c) => c.entryId === id) ?? null; + const updated = makeEntryCell({ + userId: ownerUserId!, + date: days[0], + projectId: + body.project_id === undefined + ? (existing?.project_id ?? null) + : body.project_id, + category: body.category ?? existing?.category ?? "work", + note: body.note ?? existing?.note ?? "", + rangeFrom: body.date_from, + rangeTo: body.date_to, + entryId: id, + }); + return [updated, ...prev.filter((c) => c.entryId !== id)]; + }, ); (updateEntry as any)._rolled = rolled; } @@ -338,24 +346,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [, cell] of Object.entries(byDate)) { - if ( - cell && - cell.entryId === vars.id && - cell.rangeFrom && - cell.rangeTo - ) { - const days = eachDay(cell.rangeFrom, cell.rangeTo); - const rolled = patchCells( - currentGridKey, - Number(uidStr), - days, - () => null, - ); - (deleteEntry as any)._rolled = rolled; + let range: { from: string; to: string } | null = null; + for (const cells of Object.values(byDate)) { + const hit = cells.find( + (c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo, + ); + if (hit) { + range = { from: hit.rangeFrom!, to: hit.rangeTo! }; break; } } + if (range) { + const days = eachDay(range.from, range.to); + const rolled = patchCells( + currentGridKey, + Number(uidStr), + days, + (prev) => prev.filter((c) => c.entryId !== vars.id), + ); + (deleteEntry as any)._rolled = rolled; + break; + } } } invalidate(); @@ -375,7 +386,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { currentGridKey, vars.user_id, [vars.shift_date], - () => + (prev) => [ makeOverrideCell({ userId: vars.user_id, date: vars.shift_date, @@ -384,6 +395,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { note: vars.note, overrideId: id, }), + ...prev, + ], ); (createOverride as any)._rolled = rolled; invalidate(); @@ -411,21 +424,30 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [date, cell] of Object.entries(byDate)) { - if (cell && cell.overrideId === vars.id) { + for (const [date, cells] of Object.entries(byDate)) { + if (cells.some((c) => c.overrideId === vars.id)) { const rolled = patchCells( currentGridKey, Number(uidStr), [date], - (prev) => - makeOverrideCell({ + (prev) => { + const existing = + prev.find((c) => c.overrideId === vars.id) ?? null; + const updated = makeOverrideCell({ userId: Number(uidStr), date, - projectId: vars.body.project_id ?? prev?.project_id ?? null, - category: vars.body.category ?? prev?.category ?? "work", - note: vars.body.note ?? prev?.note ?? "", + projectId: + vars.body.project_id ?? existing?.project_id ?? null, + category: + vars.body.category ?? existing?.category ?? "work", + note: vars.body.note ?? existing?.note ?? "", overrideId: vars.id, - }), + }); + return [ + updated, + ...prev.filter((c) => c.overrideId !== vars.id), + ]; + }, ); (updateOverride as any)._rolled = rolled; break; @@ -446,13 +468,13 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [date, cell] of Object.entries(byDate)) { - if (cell && cell.overrideId === vars.id) { + for (const [date, cells] of Object.entries(byDate)) { + if (cells.some((c) => c.overrideId === vars.id)) { const rolled = patchCells( currentGridKey, Number(uidStr), [date], - () => null, + (prev) => prev.filter((c) => c.overrideId !== vars.id), ); (deleteOverride as any)._rolled = rolled; break; @@ -472,15 +494,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { // directly without the "weak type" mismatch a `{ _rolled? }` param causes. function rollbackMutation(mutation: unknown, userId: number) { const stash = mutation as { - _rolled?: Record | null; + _rolled?: Record | null; }; if (!stash._rolled) return; const prev = qc.getQueryData(currentGridKey as any); if (!prev) return; const userPrev = prev.cells[userId] ?? {}; const userNext = { ...userPrev }; - for (const [date, cell] of Object.entries(stash._rolled)) { - userNext[date] = cell; + for (const [date, cells] of Object.entries(stash._rolled)) { + userNext[date] = cells; } qc.setQueryData(currentGridKey, { ...prev, @@ -518,10 +540,18 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { }; } +export function getCells( + grid: GridData | undefined, + userId: number, + date: string, +): ResolvedCell[] { + return grid?.cells?.[userId]?.[date] ?? []; +} + export function getCell( grid: GridData | undefined, userId: number, date: string, ): ResolvedCell | null { - return grid?.cells?.[userId]?.[date] ?? null; + return getCells(grid, userId, date)[0] ?? null; } diff --git a/src/admin/lib/queries/plan.ts b/src/admin/lib/queries/plan.ts index e4f8008..e253cd3 100644 --- a/src/admin/lib/queries/plan.ts +++ b/src/admin/lib/queries/plan.ts @@ -81,7 +81,7 @@ export interface GridData { date_from: string; date_to: string; users: PlanUser[]; - cells: Record>; + cells: Record>; } export const planKeys = { diff --git a/src/admin/pages/Dashboard.tsx b/src/admin/pages/Dashboard.tsx index 206a798..2f95e8a 100644 --- a/src/admin/pages/Dashboard.tsx +++ b/src/admin/pages/Dashboard.tsx @@ -72,7 +72,7 @@ interface DashData { pending_orders?: number; unpaid_invoices?: number; pending_leave_requests?: number; - today_plan?: TodayPlan | null; + today_plan?: TodayPlan[]; [key: string]: unknown; } @@ -325,7 +325,7 @@ export default function Dashboard() { {!dashLoading && (hasPermission("attendance.record") || hasPermission("attendance.manage")) && ( - + )} {/* Quick actions */} diff --git a/src/admin/pages/PlanWork.tsx b/src/admin/pages/PlanWork.tsx index f838a4e..45d2004 100644 --- a/src/admin/pages/PlanWork.tsx +++ b/src/admin/pages/PlanWork.tsx @@ -327,8 +327,9 @@ export default function PlanWork() { let firstDate: string | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [date, cell] of Object.entries(byDate)) { - if (cell && cell.entryId === id) { + for (const [date, cells] of Object.entries(byDate)) { + const hit = cells.find((c) => c.entryId === id); + if (hit) { userId = Number(uidStr); firstDate = body.date_from ?? date; break; @@ -357,10 +358,11 @@ export default function PlanWork() { let firstDate: string | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [date, cell] of Object.entries(byDate)) { - if (cell && cell.entryId === id) { + for (const [date, cells] of Object.entries(byDate)) { + const hit = cells.find((c) => c.entryId === id); + if (hit) { userId = Number(uidStr); - firstDate = cell.rangeFrom ?? date; + firstDate = hit.rangeFrom ?? date; break; } } @@ -402,8 +404,9 @@ export default function PlanWork() { let date: string | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [d, cell] of Object.entries(byDate)) { - if (cell && cell.overrideId === id) { + for (const [d, cells] of Object.entries(byDate)) { + const hit = cells.find((c) => c.overrideId === id); + if (hit) { userId = Number(uidStr); date = d; break; @@ -431,8 +434,9 @@ export default function PlanWork() { let date: string | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { - for (const [d, cell] of Object.entries(byDate)) { - if (cell && cell.overrideId === id) { + for (const [d, cells] of Object.entries(byDate)) { + const hit = cells.find((c) => c.overrideId === id); + if (hit) { userId = Number(uidStr); date = d; break; diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index a4135e8..3f3d9da 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -44,26 +44,27 @@ export default async function dashboardRoutes( } // Today's work plan (personal) — powers the "Vaše dnešní zařazení" card. - // resolveCell applies override-beats-range precedence; null = the user has - // plan access but nothing is scheduled today. The category key is enriched - // with its label + colour so the widget needs no extra query. todayStr uses - // local (Europe/Prague) calendar date — the plan's @db.Date day. + // resolveCell returns 0–3 records (override-beats-range layering); an empty + // array means the user has plan access but nothing is scheduled today. Each + // record's category key is enriched with its label + colour so the widget + // needs no extra query. todayStr uses local (Europe/Prague) calendar date — + // the plan's @db.Date day. if (has("attendance.record") || has("attendance.manage")) { const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; - const cell = await resolveCell(userId, todayStr); - if (cell) { - const cat = await prisma.plan_categories.findUnique({ - where: { key: cell.category }, - select: { label: true, color: true }, - }); - result.today_plan = { - ...cell, - category_label: cat?.label ?? cell.category, - category_color: cat?.color ?? null, - }; - } else { - result.today_plan = null; - } + const cells = await resolveCell(userId, todayStr); + result.today_plan = await Promise.all( + cells.map(async (cell) => { + const cat = await prisma.plan_categories.findUnique({ + where: { key: cell.category }, + select: { label: true, color: true }, + }); + return { + ...cell, + category_label: cat?.label ?? cell.category, + category_color: cat?.color ?? null, + }; + }), + ); } // Attendance admin — only for attendance.manage diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index 8dc66f6..9e008c2 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -108,12 +108,14 @@ export interface ResolvedCell { } /** - * Compute the effective plan cell for (userId, dateStr). + * Compute the effective plan records for (userId, dateStr) as an array of + * 0–MAX_RECORDS_PER_CELL records. * - * Precedence: override > entry > null. - * Soft-deleted rows are ignored (is_deleted = false filter). - * If multiple entries cover the same day, the latest by created_at wins - * and a warning is logged. + * Layering: if any override exists for the day, the overrides are returned + * (newest-first) and the range entries are hidden; otherwise the range + * entries covering the day are returned (newest-first). Soft-deleted rows + * are ignored (is_deleted = false filter). The result is capped at + * MAX_RECORDS_PER_CELL. * * dateStr must be a YYYY-MM-DD string. The function builds a JS Date * from it and Prisma handles timezone conversion against the MySQL @@ -122,31 +124,31 @@ export interface ResolvedCell { export async function resolveCell( userId: number, dateStr: string, -): Promise { +): Promise { const date = new Date(dateStr); - // 1. Single-day override for this exact date - const override = await prisma.work_plan_overrides.findFirst({ + const overrides = await prisma.work_plan_overrides.findMany({ where: { user_id: userId, shift_date: date, is_deleted: false }, + orderBy: { created_at: "desc" }, + take: MAX_RECORDS_PER_CELL, include: { projects: projectSelect }, }); - if (override) { - return { - source: "override", + if (overrides.length > 0) { + return overrides.map((o) => ({ + source: "override" as const, entryId: null, - overrideId: override.id, - user_id: override.user_id, + overrideId: o.id, + user_id: o.user_id, shift_date: dateStr, - project_id: override.project_id, - ...projectFields(override.projects), - category: override.category, - note: override.note, + project_id: o.project_id, + ...projectFields(o.projects), + category: o.category, + note: o.note, rangeFrom: null, rangeTo: null, - }; + })); } - // 2. Range entry that covers the day const entries = await prisma.work_plan_entries.findMany({ where: { user_id: userId, @@ -155,24 +157,11 @@ export async function resolveCell( is_deleted: false, }, orderBy: { created_at: "desc" }, + take: MAX_RECORDS_PER_CELL, include: { projects: projectSelect }, }); - - if (entries.length === 0) return null; - - if (entries.length > 1) { - // Multiple entries overlap. Use the latest. A persistent audit-log - // entry would be ideal here, but logAudit() currently requires a - // FastifyRequest — services have no request. Use console.warn as a - // stand-in until a service-context audit logger is introduced. - console.warn( - `[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`, - ); - } - - const entry = entries[0]; - return { - source: "entry", + return entries.map((entry) => ({ + source: "entry" as const, entryId: entry.id, overrideId: null, user_id: entry.user_id, @@ -183,12 +172,14 @@ export async function resolveCell( note: entry.note, rangeFrom: entry.date_from.toISOString().slice(0, 10), rangeTo: entry.date_to.toISOString().slice(0, 10), - }; + })); } /** - * Compute the effective plan cell for every (userId, date) in the - * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null. + * Compute the effective plan records for every (userId, date) in the + * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell[] + * (0–MAX_RECORDS_PER_CELL records, same layering as resolveCell; empty + * days are []). * * Implementation: load all entries and overrides for the range in two * queries, then iterate the dates and resolve each cell in O(1) per @@ -198,7 +189,7 @@ export async function resolveGrid( userIds: number[], dateFromStr: string, dateToStr: string, -): Promise>> { +): Promise>> { const dateFrom = new Date(dateFromStr); const dateTo = new Date(dateToStr); @@ -219,11 +210,11 @@ export async function resolveGrid( is_deleted: false, shift_date: { gte: dateFrom, lte: dateTo }, }, + orderBy: { created_at: "desc" }, include: { projects: projectSelect }, }), ]); - // Build a date list (inclusive) const dates: string[] = []; for ( let d = new Date(dateFrom); @@ -233,56 +224,52 @@ export async function resolveGrid( dates.push(d.toISOString().slice(0, 10)); } - const result: Record> = {}; + const result: Record> = {}; for (const uid of userIds) { result[uid] = {}; for (const dateStr of dates) { - // Override first - const override = overrides.find( - (o) => - o.user_id === uid && - o.shift_date.toISOString().slice(0, 10) === dateStr, - ); - if (override) { - result[uid][dateStr] = { - source: "override", + const day = new Date(dateStr); + const dayOverrides = overrides + .filter( + (o) => + o.user_id === uid && + o.shift_date.toISOString().slice(0, 10) === dateStr, + ) + .slice(0, MAX_RECORDS_PER_CELL); + if (dayOverrides.length > 0) { + result[uid][dateStr] = dayOverrides.map((o) => ({ + source: "override" as const, entryId: null, - overrideId: override.id, - user_id: override.user_id, + overrideId: o.id, + user_id: o.user_id, shift_date: dateStr, - project_id: override.project_id, - ...projectFields(override.projects), - category: override.category, - note: override.note, + project_id: o.project_id, + ...projectFields(o.projects), + category: o.category, + note: o.note, rangeFrom: null, rangeTo: null, - }; + })); continue; } - // First (latest by created_at) matching entry - const entry = entries.find( - (e) => - e.user_id === uid && - e.date_from <= new Date(dateStr) && - e.date_to >= new Date(dateStr), - ); - if (entry) { - result[uid][dateStr] = { - source: "entry", - entryId: entry.id, - overrideId: null, - user_id: entry.user_id, - shift_date: dateStr, - project_id: entry.project_id, - ...projectFields(entry.projects), - category: entry.category, - note: entry.note, - rangeFrom: entry.date_from.toISOString().slice(0, 10), - rangeTo: entry.date_to.toISOString().slice(0, 10), - }; - } else { - result[uid][dateStr] = null; - } + const dayEntries = entries + .filter( + (e) => e.user_id === uid && e.date_from <= day && e.date_to >= day, + ) + .slice(0, MAX_RECORDS_PER_CELL); + result[uid][dateStr] = dayEntries.map((e) => ({ + source: "entry" as const, + entryId: e.id, + overrideId: null, + user_id: e.user_id, + shift_date: dateStr, + project_id: e.project_id, + ...projectFields(e.projects), + category: e.category, + note: e.note, + rangeFrom: e.date_from.toISOString().slice(0, 10), + rangeTo: e.date_to.toISOString().slice(0, 10), + })); } }