Files
app/src/admin/components/ShiftFormModal.tsx
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00

449 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import {
Modal,
Button,
Select,
TextField,
DateField,
TimeField,
Field,
Alert,
} from "../ui";
import {
calcFormWorkMinutes,
calcProjectMinutesTotal,
formatDate,
} from "../utils/attendanceHelpers";
// ---------- Shared types ----------
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
// here so existing importers of ShiftFormModal keep working.
export type {
ShiftFormData,
ProjectLog,
Project,
User,
EditingRecord,
} from "./shiftFormTypes";
import type {
ShiftFormData,
ProjectLog,
Project,
User,
EditingRecord,
} from "./shiftFormTypes";
// ---------- Sub-component props ----------
interface ProjectTimeStatusProps {
form: ShiftFormData;
projectLogs: ProjectLog[];
}
interface ProjectLogRowProps {
log: ProjectLog;
index: number;
projectList: Project[];
onUpdate: (index: number, field: string, value: string) => void;
onRemove: (index: number) => void;
}
export interface ShiftFormModalProps {
mode: "create" | "edit";
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
form: ShiftFormData;
setForm: (form: ShiftFormData) => void;
projectLogs: ProjectLog[];
setProjectLogs: (logs: ProjectLog[]) => void;
projectList: Project[];
users: User[];
onShiftDateChange: (value: string) => void;
editingRecord: EditingRecord | null;
}
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
// ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(
projectLogs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id);
if (!hasLogs || totalWork <= 0) return null;
const isMatch = remaining === 0;
return (
<Box sx={{ mb: 1 }}>
<Alert severity={isMatch ? "success" : "error"}>
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
</Alert>
</Box>
);
}
// ---------- ProjectLogRow ----------
function ProjectLogRow({
log,
index,
projectList,
onUpdate,
onRemove,
}: ProjectLogRowProps) {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 1,
}}
>
<Box sx={{ flex: 3, minWidth: 0 }}>
<Select
value={String(log.project_id)}
onChange={(val) => onUpdate(index, "project_id", val)}
options={[
{ value: "", label: "Vyberte projekt" },
...projectList.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
<Box sx={{ width: 70, flexShrink: 0 }}>
<TextField
type="number"
value={log.hours}
onChange={(e) => onUpdate(index, "hours", e.target.value)}
placeholder="h"
slotProps={{
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
}}
/>
</Box>
<Typography variant="body2" color="text.secondary">
h
</Typography>
<Box sx={{ width: 70, flexShrink: 0 }}>
<TextField
type="number"
value={log.minutes}
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
placeholder="m"
slotProps={{
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
}}
/>
</Box>
<Typography variant="body2" color="text.secondary">
m
</Typography>
<IconButton
size="small"
onClick={() => onRemove(index)}
aria-label="Odebrat"
title="Odebrat"
sx={{ flexShrink: 0 }}
>
{RemoveIcon}
</IconButton>
</Box>
);
}
// ---------- ShiftFormModal ----------
export default function ShiftFormModal({
mode,
isOpen,
onClose,
onSubmit,
form,
setForm,
projectLogs,
setProjectLogs,
projectList,
users,
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
// Per-instance counter for stable React keys on newly-added project rows.
// (Was a module-level mutable `let`, which is shared across every modal
// instance and fragile under StrictMode / concurrent instances.)
const logKeyCounter = useRef(0);
const updateField = (field: keyof ShiftFormData, value: string | number) => {
setForm({ ...form, [field]: value });
};
const updateProjectLog = (index: number, field: string, value: string) => {
const updated = [...projectLogs];
updated[index] = { ...updated[index], [field]: value };
setProjectLogs(updated);
};
const removeProjectLog = (index: number) => {
setProjectLogs(projectLogs.filter((_, j) => j !== index));
};
const addProjectLog = () => {
setProjectLogs([
...projectLogs,
{
_key: `log-${++logKeyCounter.current}`,
project_id: "",
hours: "",
minutes: "",
},
]);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
subtitle={
!isCreate && editingRecord
? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
: undefined
}
maxWidth="lg"
onSubmit={onSubmit}
submitText="Uložit"
cancelText="Zrušit"
>
{isCreate ? (
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Zaměstnanec" required>
<Select
value={form.user_id}
onChange={(val) => updateField("user_id", val)}
options={[
{ value: "", label: "Vyberte zaměstnance" },
...users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
<Field label="Datum směny" required>
<DateField
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</Field>
</Box>
) : (
<Field label="Datum směny">
<DateField
value={form.shift_date}
onChange={(val) => updateField("shift_date", val)}
/>
</Field>
)}
<Field label="Typ záznamu">
<Select
value={form.leave_type}
onChange={(val) => updateField("leave_type", val)}
options={[
{ value: "work", label: "Práce" },
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
{!isWorkType && (
<Field
label="Počet hodin"
hint={isCreate ? "8 hodin = celý den" : undefined}
>
<TextField
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
// Empty input -> parseFloat returns NaN, which serializes to an
// invalid value; fall back to 0 like the Number(...)||0 pattern.
updateField("leave_hours", Number(e.target.value) || 0)
}
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
</Field>
)}
{isWorkType && (
<>
{/* Arrival */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)}
/>
</Field>
<Field label="Příchod - čas">
<TimeField
value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)}
/>
</Field>
</Box>
{/* Break start */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date}
onChange={(val) => updateField("break_start_date", val)}
/>
</Field>
<Field label="Začátek pauzy - čas">
<TimeField
value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)}
/>
</Field>
</Box>
{/* Break end */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</Field>
<Field label="Konec pauzy - čas">
<TimeField
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</Field>
</Box>
{/* Departure */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</Field>
<Field label="Odchod - čas">
<TimeField
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</Field>
</Box>
</>
)}
{isWorkType && projectList.length > 0 && (
<Field label="Projekty">
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
// Stable key: client-added rows carry `_key`; server-loaded rows
// carry a DB `id`. Fall back to the index only when neither is
// present (shouldn't happen, but avoids a key collision).
key={log._key ?? (log.id != null ? `id-${log.id}` : i)}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<Button
variant="outlined"
color="inherit"
size="small"
onClick={addProjectLog}
>
+ Přidat projekt
</Button>
</Field>
)}
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
/>
</Field>
</Modal>
);
}