feat(plan): day panel — list/add/edit up to 3 records per cell

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 11:12:30 +02:00
parent a146fc26a3
commit 9370423fb0
3 changed files with 159 additions and 47 deletions

View File

@@ -64,7 +64,8 @@ export type PlanCellModalMode =
note: string; note: string;
}; };
} }
| { kind: "view"; userId: number; date: string; cell: ResolvedCell }; | { kind: "view"; userId: number; date: string; cell: ResolvedCell }
| { kind: "day"; userId: number; date: string; cells: ResolvedCell[] };
interface Props { interface Props {
open: boolean; open: boolean;
@@ -84,6 +85,10 @@ 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). */
onAddRecord: (userId: number, date: string) => void;
/** Open the right editor for one of the day's existing records. */
onEditRecord: (cell: ResolvedCell, userId: number, date: string) => void;
} }
const TrashIcon = ( const TrashIcon = (
@@ -112,6 +117,7 @@ export default function PlanCellModal(props: Props) {
if (mode.kind === "view") return <ViewModal {...props} />; if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range") if (mode.kind === "day-in-range")
return <DayInRangeModal {...props} mode={mode} />; return <DayInRangeModal {...props} mode={mode} />;
if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
return <EditForm {...props} />; return <EditForm {...props} />;
} }
@@ -506,3 +512,105 @@ function ViewModal(props: Props) {
</Modal> </Modal>
); );
} }
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
function DayPanel(
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day" }> },
) {
const { mode, onClose, onAddRecord, onEditRecord, categories } = props;
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
const atCap = mode.cells.length >= 3;
const free = 3 - mode.cells.length;
return (
<Modal
isOpen
title={`Plán — ${formatDate(mode.date)}`}
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25, mb: 2 }}>
{mode.cells.map((c, i) => {
const color =
catMap[c.category]?.color || "var(--mui-palette-divider)";
const projectLabel = cellProjectLabel(c);
return (
<Card
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
variant="outlined"
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 4,
alignSelf: "stretch",
minHeight: 28,
borderRadius: 2,
bgcolor: color,
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: "0.85rem" }}>
{planCategoryLabel(c.category, catMap)}
{projectLabel ? ` · ${projectLabel}` : ""}
</Typography>
{c.note && (
<Typography variant="body2" color="text.secondary" noWrap>
{c.note}
</Typography>
)}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={EditIcon}
onClick={() => onEditRecord(c, mode.userId, mode.date)}
>
Upravit
</Button>
</Box>
</Card>
);
})}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Button
variant="contained"
color="primary"
disabled={atCap}
onClick={() => onAddRecord(mode.userId, mode.date)}
>
+ Přidat záznam
</Button>
<Typography variant="caption" color="text.secondary">
{atCap
? "Maximum 3 záznamy na den"
: `${free} ${free === 1 ? "volné místo" : "volná místa"}`}
</Typography>
</Box>
</Modal>
);
}

View File

@@ -15,6 +15,7 @@ export type ModalMode =
| { 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 }
| { kind: "day"; userId: number; date: string }
| { kind: "view"; userId: number; date: string }; | { kind: "view"; userId: number; date: string };
function isoDate(d: Date): string { function isoDate(d: Date): string {

View File

@@ -8,6 +8,7 @@ import { useAlert } from "../context/AlertContext";
import { import {
usePlanWork, usePlanWork,
getCell, getCell,
getCells,
type ModalMode as HookModalMode, type ModalMode as HookModalMode,
} from "../hooks/usePlanWork"; } from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal"; import type { PlanCellModalMode } from "../components/PlanCellModal";
@@ -206,76 +207,68 @@ export default function PlanWork() {
); );
const openCell = useCallback( const openCell = useCallback(
(userId: number, date: string, _cells: ResolvedCell[]) => { (userId: number, date: string, cells: ResolvedCell[]) => {
void _cells; const primary = cells[0] ?? null;
const cell = getCell(grid, userId, date);
// Past-date guard: the server rejects create/edit on past dates with // Past-date guard: the server rejects create/edit on past dates with
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user // a 403 (see assertNotPastDate in plan.service.ts). To keep the user
// out of the "click → modal opens → error toast" state, route any // out of the "click → modal opens → error toast" state, route any
// past-day click to view mode (or to no-op if there's no record). // past-day click to view mode (or to no-op if there's no record).
if (isPastDate(date, todayIsoLocal())) { if (isPastDate(date, todayIsoLocal())) {
if (!cell) { if (!primary) return;
// Empty past cell — nothing to show or edit. setModalCell(primary);
return;
}
setModalCell(cell);
setHookModal({ kind: "view", userId, date }); setHookModal({ kind: "view", userId, date });
return; return;
} }
if (!cell) { if (cells.length === 0) {
// Empty cell — open the create modal if the user can edit, otherwise
// there's nothing to view.
if (canEdit) openCreate(userId, date); if (canEdit) openCreate(userId, date);
return; return;
} }
if (!canEdit) {
setModalCell(primary);
setHookModal({ kind: "view", userId, date });
return;
}
setHookModal({ kind: "day", userId, date });
},
[canEdit, openCreate, setHookModal],
);
// Open the correct editor for one record from the day panel. This is the
// per-record version of the old openCell branch logic: a multi-day entry
// routes through the day-in-range chooser; a single-day entry → edit-range;
// an override → edit-override.
const editRecord = useCallback(
(cell: ReturnType<typeof getCell>, userId: number, date: string) => {
if (!cell) return;
setModalCell(cell); setModalCell(cell);
if (cell.source === "entry" && cell.entryId !== null) { if (cell.source === "entry" && cell.entryId !== null) {
if (canEdit) { if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) {
// A multi-day entry clicked on a single day → "day-in-range" so the setHookModal({
// user can decide between editing the range or creating a one-day kind: "day-in-range",
// override. entryId: cell.entryId,
if ( userId,
cell.rangeFrom && date,
cell.rangeTo && });
cell.rangeFrom !== cell.rangeTo } else {
) {
setHookModal({
kind: "day-in-range",
entryId: cell.entryId,
userId,
date,
});
return;
}
setHookModal({ setHookModal({
kind: "edit-range", kind: "edit-range",
entryId: cell.entryId, entryId: cell.entryId,
userId, userId,
date, date,
}); });
return;
} }
// Read-only view
setHookModal({ kind: "view", userId, date });
return; return;
} }
if (cell.source === "override" && cell.overrideId !== null) { if (cell.source === "override" && cell.overrideId !== null) {
if (canEdit) { setHookModal({
setHookModal({ kind: "edit-override",
kind: "edit-override", overrideId: cell.overrideId,
overrideId: cell.overrideId, userId,
userId, date,
date, });
});
} else {
setHookModal({ kind: "view", userId, date });
}
return;
} }
// No source — treat as empty
if (canEdit) openCreate(userId, date);
}, },
[grid, canEdit, openCreate, setHookModal], [setHookModal],
); );
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
@@ -545,8 +538,16 @@ export default function PlanWork() {
}, },
} as PlanCellModalMode; } as PlanCellModalMode;
} }
if (hookModal.kind === "day") {
return {
kind: "day",
userId: hookModal.userId,
date: hookModal.date,
cells: getCells(grid, hookModal.userId, hookModal.date),
};
}
return { kind: "closed" }; return { kind: "closed" };
}, [hookModal, modalCell]); }, [hookModal, modalCell, grid]);
if (!canEdit && !hasPermission("attendance.record")) { if (!canEdit && !hasPermission("attendance.record")) {
// Hard block for users who have neither manage nor record permission. // Hard block for users who have neither manage nor record permission.
@@ -700,6 +701,8 @@ export default function PlanWork() {
onDeleteOverride={deleteOverrideFn} onDeleteOverride={deleteOverrideFn}
onCreateOverrideFromRange={createOverrideFromRange} onCreateOverrideFromRange={createOverrideFromRange}
onSwitchToEditRange={switchToEditRange} onSwitchToEditRange={switchToEditRange}
onAddRecord={openCreate}
onEditRecord={editRecord}
/> />
<PlanCategoriesModal <PlanCategoriesModal
isOpen={catModalOpen} isOpen={catModalOpen}