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;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user