feat(plan): resolveCell/resolveGrid return arrays; dashboard shows up to 3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 10:53:26 +02:00
parent 80dc8a5c69
commit 72888bf9cd
9 changed files with 320 additions and 256 deletions

View File

@@ -89,10 +89,9 @@ afterAll(async () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("plan.service.resolveCell", () => { describe("plan.service.resolveCell", () => {
it("returns null when nothing covers the date", async () => { it("returns [] when nothing covers the date", async () => {
// No entry, no override for (adminUserId, "2099-01-01")
const result = await resolveCell(adminUserId, "2099-01-01"); const result = await resolveCell(adminUserId, "2099-01-01");
expect(result).toBeNull(); expect(result).toEqual([]);
}); });
it("returns the entry that covers the date", async () => { 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"); const result = await resolveCell(adminUserId, "2099-06-05");
expect(result).not.toBeNull(); expect(result.length).toBe(1);
expect(result?.source).toBe("entry"); expect(result[0].source).toBe("entry");
expect(result?.note).toBe(`${N}PLC upgrade`); expect(result[0].note).toBe(`${N}PLC upgrade`);
expect(result?.category).toBe("work"); expect(result[0].rangeFrom).toBe("2099-06-01");
expect(result?.rangeFrom).toBe("2099-06-01"); expect(result[0].rangeTo).toBe("2099-06-10");
expect(result?.rangeTo).toBe("2099-06-10");
}); });
it("returns the override for that day, not the entry", async () => { it("returns overrides (entries hidden) when an override exists", async () => {
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
// takes precedence.
await prisma.work_plan_entries.create({ await prisma.work_plan_entries.create({
data: { data: {
user_id: adminUserId, user_id: adminUserId,
@@ -138,10 +134,51 @@ describe("plan.service.resolveCell", () => {
}, },
}); });
const result = await resolveCell(adminUserId, "2099-06-05"); const result = await resolveCell(adminUserId, "2099-06-05");
expect(result).not.toBeNull(); expect(result.length).toBe(1);
expect(result?.source).toBe("override"); expect(result[0].source).toBe("override");
expect(result?.note).toBe(`${N}Volno po noční`); expect(result[0].note).toBe(`${N}Volno po noční`);
expect(result?.category).toBe("leave"); });
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 () => { it("ignores soft-deleted entries and overrides", async () => {
@@ -157,7 +194,7 @@ describe("plan.service.resolveCell", () => {
}, },
}); });
const result = await resolveCell(adminUserId, "2099-06-05"); 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"); 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-01"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-04"]).toBeNull(); expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
expect(cells[adminUserId]["2099-06-05"]).toBeNull(); expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
}); });
it("applies override on a covered day", async () => { 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"); 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-01"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`); expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`); expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
}); });
}); });
@@ -857,7 +894,7 @@ describe("HTTP /api/admin/plan", () => {
const body = res.json(); const body = res.json();
expect(body.data.users).toBeDefined(); expect(body.data.users).toBeDefined();
expect(body.data.cells[adminUserId]).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`, `${N}grid test`,
); );
}); });

View File

@@ -529,7 +529,8 @@ export default function PlanGrid({
</span> </span>
</td> </td>
{users.map((u) => { {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); const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past // Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint), // cell is non-interactive (no create modal, no "+" hint),

View File

@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
import { Card } from "../../ui"; import { Card } from "../../ui";
import { formatDate } from "../../utils/formatters"; 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 { export interface TodayPlan {
shift_date: string; shift_date: string;
category: string; category: string;
@@ -23,15 +23,64 @@ function projectLabel(p: TodayPlan): string {
return parts.length > 0 ? parts.join(" — ") : "Bez projektu"; return parts.length > 0 ? parts.join(" — ") : "Bez projektu";
} }
/** function PlanRow({ plan }: { plan: TodayPlan }) {
* "Vaše dnešní zařazení" — shows the logged-in user's work-plan entry for today const color = plan.category_color || "var(--mui-palette-primary-main)";
* (category + project/site + note, with a range badge when the day is part of a return (
* multi-day range). `plan === null` = the user has plan access but nothing is <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
* scheduled today (muted empty state). Rendered near the clock-in on the <Box
* dashboard; the caller gates it on the plan permission. sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}
*/ >
export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) { <Box
const color = plan?.category_color || "var(--mui-palette-primary-main)"; sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
borderColor: "divider",
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
>
{plan.category_label}
</Typography>
</Box>
{plan.rangeFrom && plan.rangeTo && plan.rangeFrom !== plan.rangeTo && (
<Typography variant="caption" color="text.secondary">
součást rozsahu {formatDate(plan.rangeFrom)} {" "}
{formatDate(plan.rangeTo)}
</Typography>
)}
</Box>
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
{projectLabel(plan)}
</Typography>
{plan.note && (
<Typography variant="body2" color="text.secondary">
{plan.note}
</Typography>
)}
</Box>
);
}
/** "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 ( return (
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
<Typography <Typography
@@ -40,65 +89,20 @@ export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) {
> >
Vaše dnešní zařazení Vaše dnešní zařazení
</Typography> </Typography>
{plans.length > 0 ? (
{plan ? ( <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> {plans.map((p, i) => (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box <Box
key={i}
sx={{ sx={{
display: "inline-flex", pt: i === 0 ? 0 : 2,
alignItems: "center", borderTop: i === 0 ? 0 : 1,
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
borderColor: "divider", borderColor: "divider",
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
}} }}
> >
<Box <PlanRow plan={p} />
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
>
{plan.category_label}
</Typography>
</Box> </Box>
{plan.rangeFrom && ))}
plan.rangeTo &&
plan.rangeFrom !== plan.rangeTo && (
<Typography variant="caption" color="text.secondary">
součást rozsahu {formatDate(plan.rangeFrom)} {" "}
{formatDate(plan.rangeTo)}
</Typography>
)}
</Box>
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
{projectLabel(plan)}
</Typography>
{plan.note && (
<Typography variant="body2" color="text.secondary">
{plan.note}
</Typography>
)}
</Box> </Box>
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">

View File

@@ -150,16 +150,16 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
key: readonly unknown[], key: readonly unknown[],
userId: number, userId: number,
dates: string[], dates: string[],
mutator: (prev: ResolvedCell | null) => ResolvedCell | null, mutator: (prev: ResolvedCell[]) => ResolvedCell[],
): Record<string, ResolvedCell | null> | null { ): Record<string, ResolvedCell[]> | null {
const prev = snapshotGrid(key); const prev = snapshotGrid(key);
if (!prev) return null; if (!prev) return null;
const userPrev = prev.cells[userId] ?? {}; const userPrev = prev.cells[userId] ?? {};
const rolled: Record<string, ResolvedCell | null> = {}; const rolled: Record<string, ResolvedCell[]> = {};
const userNext: Record<string, ResolvedCell | null> = { ...userPrev }; const userNext: Record<string, ResolvedCell[]> = { ...userPrev };
for (const date of dates) { for (const date of dates) {
rolled[date] = userPrev[date] ?? null; rolled[date] = userPrev[date] ?? [];
userNext[date] = mutator(rolled[date]); userNext[date] = mutator(rolled[date]).slice(0, 3);
} }
qc.setQueryData(key, { qc.setQueryData(key, {
...prev, ...prev,
@@ -244,7 +244,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// the ResolvedCell mirrors the request body. // the ResolvedCell mirrors the request body.
const days = eachDay(body.date_from, body.date_to); const days = eachDay(body.date_from, body.date_to);
const id = data && typeof data.id === "number" ? data.id : null; 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({ makeEntryCell({
userId: body.user_id, userId: body.user_id,
date: days[0], date: days[0],
@@ -255,7 +255,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
rangeTo: body.date_to, rangeTo: body.date_to,
entryId: id, entryId: id,
}), }),
); ...prev,
]);
// Stash the rollback on the mutation object so PlanWork can call // Stash the rollback on the mutation object so PlanWork can call
// it from onError. // it from onError.
(createEntry as any)._rolled = rolled; (createEntry as any)._rolled = rolled;
@@ -294,8 +295,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
let ownerUserId: number | null = null; let ownerUserId: number | null = null;
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const cell of Object.values(byDate)) { for (const cells of Object.values(byDate)) {
if (cell && cell.entryId === id) { if (cells.some((c) => c.entryId === id)) {
ownerUserId = Number(uidStr); ownerUserId = Number(uidStr);
break; break;
} }
@@ -304,20 +305,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
} }
} }
if (ownerUserId !== null) { if (ownerUserId !== null) {
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) => const rolled = patchCells(
makeEntryCell({ currentGridKey,
userId: ownerUserId!, ownerUserId,
date: days[0], days,
projectId: (prev) => {
body.project_id === undefined const existing = prev.find((c) => c.entryId === id) ?? null;
? (prev?.project_id ?? null) const updated = makeEntryCell({
: body.project_id, userId: ownerUserId!,
category: body.category ?? prev?.category ?? "work", date: days[0],
note: body.note ?? prev?.note ?? "", projectId:
rangeFrom: body.date_from, body.project_id === undefined
rangeTo: body.date_to, ? (existing?.project_id ?? null)
entryId: id, : 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; (updateEntry as any)._rolled = rolled;
} }
@@ -338,24 +346,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey as any);
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [, cell] of Object.entries(byDate)) { let range: { from: string; to: string } | null = null;
if ( for (const cells of Object.values(byDate)) {
cell && const hit = cells.find(
cell.entryId === vars.id && (c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
cell.rangeFrom && );
cell.rangeTo if (hit) {
) { range = { from: hit.rangeFrom!, to: hit.rangeTo! };
const days = eachDay(cell.rangeFrom, cell.rangeTo);
const rolled = patchCells(
currentGridKey,
Number(uidStr),
days,
() => null,
);
(deleteEntry as any)._rolled = rolled;
break; 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(); invalidate();
@@ -375,7 +386,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
currentGridKey, currentGridKey,
vars.user_id, vars.user_id,
[vars.shift_date], [vars.shift_date],
() => (prev) => [
makeOverrideCell({ makeOverrideCell({
userId: vars.user_id, userId: vars.user_id,
date: vars.shift_date, date: vars.shift_date,
@@ -384,6 +395,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
note: vars.note, note: vars.note,
overrideId: id, overrideId: id,
}), }),
...prev,
],
); );
(createOverride as any)._rolled = rolled; (createOverride as any)._rolled = rolled;
invalidate(); invalidate();
@@ -411,21 +424,30 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey as any);
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) { for (const [date, cells] of Object.entries(byDate)) {
if (cell && cell.overrideId === vars.id) { if (cells.some((c) => c.overrideId === vars.id)) {
const rolled = patchCells( const rolled = patchCells(
currentGridKey, currentGridKey,
Number(uidStr), Number(uidStr),
[date], [date],
(prev) => (prev) => {
makeOverrideCell({ const existing =
prev.find((c) => c.overrideId === vars.id) ?? null;
const updated = makeOverrideCell({
userId: Number(uidStr), userId: Number(uidStr),
date, date,
projectId: vars.body.project_id ?? prev?.project_id ?? null, projectId:
category: vars.body.category ?? prev?.category ?? "work", vars.body.project_id ?? existing?.project_id ?? null,
note: vars.body.note ?? prev?.note ?? "", category:
vars.body.category ?? existing?.category ?? "work",
note: vars.body.note ?? existing?.note ?? "",
overrideId: vars.id, overrideId: vars.id,
}), });
return [
updated,
...prev.filter((c) => c.overrideId !== vars.id),
];
},
); );
(updateOverride as any)._rolled = rolled; (updateOverride as any)._rolled = rolled;
break; break;
@@ -446,13 +468,13 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey as any);
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) { for (const [date, cells] of Object.entries(byDate)) {
if (cell && cell.overrideId === vars.id) { if (cells.some((c) => c.overrideId === vars.id)) {
const rolled = patchCells( const rolled = patchCells(
currentGridKey, currentGridKey,
Number(uidStr), Number(uidStr),
[date], [date],
() => null, (prev) => prev.filter((c) => c.overrideId !== vars.id),
); );
(deleteOverride as any)._rolled = rolled; (deleteOverride as any)._rolled = rolled;
break; break;
@@ -472,15 +494,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// directly without the "weak type" mismatch a `{ _rolled? }` param causes. // directly without the "weak type" mismatch a `{ _rolled? }` param causes.
function rollbackMutation(mutation: unknown, userId: number) { function rollbackMutation(mutation: unknown, userId: number) {
const stash = mutation as { const stash = mutation as {
_rolled?: Record<string, ResolvedCell | null> | null; _rolled?: Record<string, ResolvedCell[]> | null;
}; };
if (!stash._rolled) return; if (!stash._rolled) return;
const prev = qc.getQueryData<GridData>(currentGridKey as any); const prev = qc.getQueryData<GridData>(currentGridKey as any);
if (!prev) return; if (!prev) return;
const userPrev = prev.cells[userId] ?? {}; const userPrev = prev.cells[userId] ?? {};
const userNext = { ...userPrev }; const userNext = { ...userPrev };
for (const [date, cell] of Object.entries(stash._rolled)) { for (const [date, cells] of Object.entries(stash._rolled)) {
userNext[date] = cell; userNext[date] = cells;
} }
qc.setQueryData(currentGridKey, { qc.setQueryData(currentGridKey, {
...prev, ...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( export function getCell(
grid: GridData | undefined, grid: GridData | undefined,
userId: number, userId: number,
date: string, date: string,
): ResolvedCell | null { ): ResolvedCell | null {
return grid?.cells?.[userId]?.[date] ?? null; return getCells(grid, userId, date)[0] ?? null;
} }

View File

@@ -81,7 +81,7 @@ export interface GridData {
date_from: string; date_from: string;
date_to: string; date_to: string;
users: PlanUser[]; users: PlanUser[];
cells: Record<number, Record<string, ResolvedCell | null>>; cells: Record<number, Record<string, ResolvedCell[]>>;
} }
export const planKeys = { export const planKeys = {

View File

@@ -72,7 +72,7 @@ interface DashData {
pending_orders?: number; pending_orders?: number;
unpaid_invoices?: number; unpaid_invoices?: number;
pending_leave_requests?: number; pending_leave_requests?: number;
today_plan?: TodayPlan | null; today_plan?: TodayPlan[];
[key: string]: unknown; [key: string]: unknown;
} }
@@ -325,7 +325,7 @@ export default function Dashboard() {
{!dashLoading && {!dashLoading &&
(hasPermission("attendance.record") || (hasPermission("attendance.record") ||
hasPermission("attendance.manage")) && ( hasPermission("attendance.manage")) && (
<DashTodayPlan plan={dashData?.today_plan ?? null} /> <DashTodayPlan plans={dashData?.today_plan ?? []} />
)} )}
{/* Quick actions */} {/* Quick actions */}

View File

@@ -327,8 +327,9 @@ export default function PlanWork() {
let firstDate: string | null = null; let firstDate: string | null = null;
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) { for (const [date, cells] of Object.entries(byDate)) {
if (cell && cell.entryId === id) { const hit = cells.find((c) => c.entryId === id);
if (hit) {
userId = Number(uidStr); userId = Number(uidStr);
firstDate = body.date_from ?? date; firstDate = body.date_from ?? date;
break; break;
@@ -357,10 +358,11 @@ export default function PlanWork() {
let firstDate: string | null = null; let firstDate: string | null = null;
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) { for (const [date, cells] of Object.entries(byDate)) {
if (cell && cell.entryId === id) { const hit = cells.find((c) => c.entryId === id);
if (hit) {
userId = Number(uidStr); userId = Number(uidStr);
firstDate = cell.rangeFrom ?? date; firstDate = hit.rangeFrom ?? date;
break; break;
} }
} }
@@ -402,8 +404,9 @@ export default function PlanWork() {
let date: string | null = null; let date: string | null = null;
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cell] of Object.entries(byDate)) { for (const [d, cells] of Object.entries(byDate)) {
if (cell && cell.overrideId === id) { const hit = cells.find((c) => c.overrideId === id);
if (hit) {
userId = Number(uidStr); userId = Number(uidStr);
date = d; date = d;
break; break;
@@ -431,8 +434,9 @@ export default function PlanWork() {
let date: string | null = null; let date: string | null = null;
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cell] of Object.entries(byDate)) { for (const [d, cells] of Object.entries(byDate)) {
if (cell && cell.overrideId === id) { const hit = cells.find((c) => c.overrideId === id);
if (hit) {
userId = Number(uidStr); userId = Number(uidStr);
date = d; date = d;
break; break;

View File

@@ -44,26 +44,27 @@ export default async function dashboardRoutes(
} }
// Today's work plan (personal) — powers the "Vaše dnešní zařazení" card. // Today's work plan (personal) — powers the "Vaše dnešní zařazení" card.
// resolveCell applies override-beats-range precedence; null = the user has // resolveCell returns 03 records (override-beats-range layering); an empty
// plan access but nothing is scheduled today. The category key is enriched // array means the user has plan access but nothing is scheduled today. Each
// with its label + colour so the widget needs no extra query. todayStr uses // record's category key is enriched with its label + colour so the widget
// local (Europe/Prague) calendar date — the plan's @db.Date day. // needs no extra query. todayStr uses local (Europe/Prague) calendar date —
// the plan's @db.Date day.
if (has("attendance.record") || has("attendance.manage")) { 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 todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const cell = await resolveCell(userId, todayStr); const cells = await resolveCell(userId, todayStr);
if (cell) { result.today_plan = await Promise.all(
const cat = await prisma.plan_categories.findUnique({ cells.map(async (cell) => {
where: { key: cell.category }, const cat = await prisma.plan_categories.findUnique({
select: { label: true, color: true }, where: { key: cell.category },
}); select: { label: true, color: true },
result.today_plan = { });
...cell, return {
category_label: cat?.label ?? cell.category, ...cell,
category_color: cat?.color ?? null, category_label: cat?.label ?? cell.category,
}; category_color: cat?.color ?? null,
} else { };
result.today_plan = null; }),
} );
} }
// Attendance admin — only for attendance.manage // Attendance admin — only for attendance.manage

View File

@@ -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
* 0MAX_RECORDS_PER_CELL records.
* *
* Precedence: override > entry > null. * Layering: if any override exists for the day, the overrides are returned
* Soft-deleted rows are ignored (is_deleted = false filter). * (newest-first) and the range entries are hidden; otherwise the range
* If multiple entries cover the same day, the latest by created_at wins * entries covering the day are returned (newest-first). Soft-deleted rows
* and a warning is logged. * 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 * dateStr must be a YYYY-MM-DD string. The function builds a JS Date
* from it and Prisma handles timezone conversion against the MySQL * from it and Prisma handles timezone conversion against the MySQL
@@ -122,31 +124,31 @@ export interface ResolvedCell {
export async function resolveCell( export async function resolveCell(
userId: number, userId: number,
dateStr: string, dateStr: string,
): Promise<ResolvedCell | null> { ): Promise<ResolvedCell[]> {
const date = new Date(dateStr); const date = new Date(dateStr);
// 1. Single-day override for this exact date const overrides = await prisma.work_plan_overrides.findMany({
const override = await prisma.work_plan_overrides.findFirst({
where: { user_id: userId, shift_date: date, is_deleted: false }, where: { user_id: userId, shift_date: date, is_deleted: false },
orderBy: { created_at: "desc" },
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect }, include: { projects: projectSelect },
}); });
if (override) { if (overrides.length > 0) {
return { return overrides.map((o) => ({
source: "override", source: "override" as const,
entryId: null, entryId: null,
overrideId: override.id, overrideId: o.id,
user_id: override.user_id, user_id: o.user_id,
shift_date: dateStr, shift_date: dateStr,
project_id: override.project_id, project_id: o.project_id,
...projectFields(override.projects), ...projectFields(o.projects),
category: override.category, category: o.category,
note: override.note, note: o.note,
rangeFrom: null, rangeFrom: null,
rangeTo: null, rangeTo: null,
}; }));
} }
// 2. Range entry that covers the day
const entries = await prisma.work_plan_entries.findMany({ const entries = await prisma.work_plan_entries.findMany({
where: { where: {
user_id: userId, user_id: userId,
@@ -155,24 +157,11 @@ export async function resolveCell(
is_deleted: false, is_deleted: false,
}, },
orderBy: { created_at: "desc" }, orderBy: { created_at: "desc" },
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect }, include: { projects: projectSelect },
}); });
return entries.map((entry) => ({
if (entries.length === 0) return null; source: "entry" as const,
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",
entryId: entry.id, entryId: entry.id,
overrideId: null, overrideId: null,
user_id: entry.user_id, user_id: entry.user_id,
@@ -183,12 +172,14 @@ export async function resolveCell(
note: entry.note, note: entry.note,
rangeFrom: entry.date_from.toISOString().slice(0, 10), rangeFrom: entry.date_from.toISOString().slice(0, 10),
rangeTo: entry.date_to.toISOString().slice(0, 10), rangeTo: entry.date_to.toISOString().slice(0, 10),
}; }));
} }
/** /**
* Compute the effective plan cell for every (userId, date) in the * Compute the effective plan records for every (userId, date) in the
* given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null. * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell[]
* (0MAX_RECORDS_PER_CELL records, same layering as resolveCell; empty
* days are []).
* *
* Implementation: load all entries and overrides for the range in two * Implementation: load all entries and overrides for the range in two
* queries, then iterate the dates and resolve each cell in O(1) per * queries, then iterate the dates and resolve each cell in O(1) per
@@ -198,7 +189,7 @@ export async function resolveGrid(
userIds: number[], userIds: number[],
dateFromStr: string, dateFromStr: string,
dateToStr: string, dateToStr: string,
): Promise<Record<number, Record<string, ResolvedCell | null>>> { ): Promise<Record<number, Record<string, ResolvedCell[]>>> {
const dateFrom = new Date(dateFromStr); const dateFrom = new Date(dateFromStr);
const dateTo = new Date(dateToStr); const dateTo = new Date(dateToStr);
@@ -219,11 +210,11 @@ export async function resolveGrid(
is_deleted: false, is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo }, shift_date: { gte: dateFrom, lte: dateTo },
}, },
orderBy: { created_at: "desc" },
include: { projects: projectSelect }, include: { projects: projectSelect },
}), }),
]); ]);
// Build a date list (inclusive)
const dates: string[] = []; const dates: string[] = [];
for ( for (
let d = new Date(dateFrom); let d = new Date(dateFrom);
@@ -233,56 +224,52 @@ export async function resolveGrid(
dates.push(d.toISOString().slice(0, 10)); dates.push(d.toISOString().slice(0, 10));
} }
const result: Record<number, Record<string, ResolvedCell | null>> = {}; const result: Record<number, Record<string, ResolvedCell[]>> = {};
for (const uid of userIds) { for (const uid of userIds) {
result[uid] = {}; result[uid] = {};
for (const dateStr of dates) { for (const dateStr of dates) {
// Override first const day = new Date(dateStr);
const override = overrides.find( const dayOverrides = overrides
(o) => .filter(
o.user_id === uid && (o) =>
o.shift_date.toISOString().slice(0, 10) === dateStr, o.user_id === uid &&
); o.shift_date.toISOString().slice(0, 10) === dateStr,
if (override) { )
result[uid][dateStr] = { .slice(0, MAX_RECORDS_PER_CELL);
source: "override", if (dayOverrides.length > 0) {
result[uid][dateStr] = dayOverrides.map((o) => ({
source: "override" as const,
entryId: null, entryId: null,
overrideId: override.id, overrideId: o.id,
user_id: override.user_id, user_id: o.user_id,
shift_date: dateStr, shift_date: dateStr,
project_id: override.project_id, project_id: o.project_id,
...projectFields(override.projects), ...projectFields(o.projects),
category: override.category, category: o.category,
note: override.note, note: o.note,
rangeFrom: null, rangeFrom: null,
rangeTo: null, rangeTo: null,
}; }));
continue; continue;
} }
// First (latest by created_at) matching entry const dayEntries = entries
const entry = entries.find( .filter(
(e) => (e) => e.user_id === uid && e.date_from <= day && e.date_to >= day,
e.user_id === uid && )
e.date_from <= new Date(dateStr) && .slice(0, MAX_RECORDS_PER_CELL);
e.date_to >= new Date(dateStr), result[uid][dateStr] = dayEntries.map((e) => ({
); source: "entry" as const,
if (entry) { entryId: e.id,
result[uid][dateStr] = { overrideId: null,
source: "entry", user_id: e.user_id,
entryId: entry.id, shift_date: dateStr,
overrideId: null, project_id: e.project_id,
user_id: entry.user_id, ...projectFields(e.projects),
shift_date: dateStr, category: e.category,
project_id: entry.project_id, note: e.note,
...projectFields(entry.projects), rangeFrom: e.date_from.toISOString().slice(0, 10),
category: entry.category, rangeTo: e.date_to.toISOString().slice(0, 10),
note: entry.note, }));
rangeFrom: entry.date_from.toISOString().slice(0, 10),
rangeTo: entry.date_to.toISOString().slice(0, 10),
};
} else {
result[uid][dateStr] = null;
}
} }
} }