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

@@ -529,7 +529,8 @@ export default function PlanGrid({
</span>
</td>
{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),

View File

@@ -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 (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}
>
<Box
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 (
<Card sx={{ mb: 3 }}>
<Typography
@@ -40,65 +89,20 @@ export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) {
>
Vaše dnešní zařazení
</Typography>
{plan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
{plans.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{plans.map((p, i) => (
<Box
key={i}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
pt: i === 0 ? 0 : 2,
borderTop: i === 0 ? 0 : 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>
<PlanRow plan={p} />
</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>
) : (
<Typography variant="body2" color="text.secondary">

View File

@@ -150,16 +150,16 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
key: readonly unknown[],
userId: number,
dates: string[],
mutator: (prev: ResolvedCell | null) => ResolvedCell | null,
): Record<string, ResolvedCell | null> | null {
mutator: (prev: ResolvedCell[]) => ResolvedCell[],
): Record<string, ResolvedCell[]> | null {
const prev = snapshotGrid(key);
if (!prev) return null;
const userPrev = prev.cells[userId] ?? {};
const rolled: Record<string, ResolvedCell | null> = {};
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
const rolled: Record<string, ResolvedCell[]> = {};
const userNext: Record<string, ResolvedCell[]> = { ...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<GridData>(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<GridData>(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<GridData>(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<string, ResolvedCell | null> | null;
_rolled?: Record<string, ResolvedCell[]> | null;
};
if (!stash._rolled) return;
const prev = qc.getQueryData<GridData>(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;
}

View File

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

View File

@@ -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")) && (
<DashTodayPlan plan={dashData?.today_plan ?? null} />
<DashTodayPlan plans={dashData?.today_plan ?? []} />
)}
{/* Quick actions */}

View File

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