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:
@@ -64,7 +64,8 @@ export type PlanCellModalMode =
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -84,6 +85,10 @@ interface Props {
|
||||
date: string,
|
||||
) => Promise<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 = (
|
||||
@@ -112,6 +117,7 @@ export default function PlanCellModal(props: Props) {
|
||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||
if (mode.kind === "day-in-range")
|
||||
return <DayInRangeModal {...props} mode={mode} />;
|
||||
if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
|
||||
return <EditForm {...props} />;
|
||||
}
|
||||
|
||||
@@ -506,3 +512,105 @@ function ViewModal(props: Props) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ModalMode =
|
||||
| { kind: "edit-range"; entryId: 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"; userId: number; date: string }
|
||||
| { kind: "view"; userId: number; date: string };
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAlert } from "../context/AlertContext";
|
||||
import {
|
||||
usePlanWork,
|
||||
getCell,
|
||||
getCells,
|
||||
type ModalMode as HookModalMode,
|
||||
} from "../hooks/usePlanWork";
|
||||
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
||||
@@ -206,76 +207,68 @@ export default function PlanWork() {
|
||||
);
|
||||
|
||||
const openCell = useCallback(
|
||||
(userId: number, date: string, _cells: ResolvedCell[]) => {
|
||||
void _cells;
|
||||
const cell = getCell(grid, userId, date);
|
||||
(userId: number, date: string, cells: ResolvedCell[]) => {
|
||||
const primary = cells[0] ?? null;
|
||||
// Past-date guard: the server rejects create/edit on past dates with
|
||||
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
|
||||
// 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).
|
||||
if (isPastDate(date, todayIsoLocal())) {
|
||||
if (!cell) {
|
||||
// Empty past cell — nothing to show or edit.
|
||||
return;
|
||||
}
|
||||
setModalCell(cell);
|
||||
if (!primary) return;
|
||||
setModalCell(primary);
|
||||
setHookModal({ kind: "view", userId, date });
|
||||
return;
|
||||
}
|
||||
if (!cell) {
|
||||
// Empty cell — open the create modal if the user can edit, otherwise
|
||||
// there's nothing to view.
|
||||
if (cells.length === 0) {
|
||||
if (canEdit) openCreate(userId, date);
|
||||
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);
|
||||
if (cell.source === "entry" && cell.entryId !== null) {
|
||||
if (canEdit) {
|
||||
// A multi-day entry clicked on a single day → "day-in-range" so the
|
||||
// user can decide between editing the range or creating a one-day
|
||||
// override.
|
||||
if (
|
||||
cell.rangeFrom &&
|
||||
cell.rangeTo &&
|
||||
cell.rangeFrom !== cell.rangeTo
|
||||
) {
|
||||
setHookModal({
|
||||
kind: "day-in-range",
|
||||
entryId: cell.entryId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) {
|
||||
setHookModal({
|
||||
kind: "day-in-range",
|
||||
entryId: cell.entryId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
} else {
|
||||
setHookModal({
|
||||
kind: "edit-range",
|
||||
entryId: cell.entryId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Read-only view
|
||||
setHookModal({ kind: "view", userId, date });
|
||||
return;
|
||||
}
|
||||
if (cell.source === "override" && cell.overrideId !== null) {
|
||||
if (canEdit) {
|
||||
setHookModal({
|
||||
kind: "edit-override",
|
||||
overrideId: cell.overrideId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
} else {
|
||||
setHookModal({ kind: "view", userId, date });
|
||||
}
|
||||
return;
|
||||
setHookModal({
|
||||
kind: "edit-override",
|
||||
overrideId: cell.overrideId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
}
|
||||
// No source — treat as empty
|
||||
if (canEdit) openCreate(userId, date);
|
||||
},
|
||||
[grid, canEdit, openCreate, setHookModal],
|
||||
[setHookModal],
|
||||
);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
@@ -545,8 +538,16 @@ export default function PlanWork() {
|
||||
},
|
||||
} 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" };
|
||||
}, [hookModal, modalCell]);
|
||||
}, [hookModal, modalCell, grid]);
|
||||
|
||||
if (!canEdit && !hasPermission("attendance.record")) {
|
||||
// Hard block for users who have neither manage nor record permission.
|
||||
@@ -700,6 +701,8 @@ export default function PlanWork() {
|
||||
onDeleteOverride={deleteOverrideFn}
|
||||
onCreateOverrideFromRange={createOverrideFromRange}
|
||||
onSwitchToEditRange={switchToEditRange}
|
||||
onAddRecord={openCreate}
|
||||
onEditRecord={editRecord}
|
||||
/>
|
||||
<PlanCategoriesModal
|
||||
isOpen={catModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user