464 lines
12 KiB
TypeScript
464 lines
12 KiB
TypeScript
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";
|
||
|
||
let _logKeyCounter = 0;
|
||
|
||
// ---------- Shared types ----------
|
||
|
||
export interface ShiftFormData {
|
||
user_id: string;
|
||
shift_date: string;
|
||
leave_type: string;
|
||
leave_hours: number;
|
||
arrival_date: string;
|
||
arrival_time: string;
|
||
break_start_date: string;
|
||
break_start_time: string;
|
||
break_end_date: string;
|
||
break_end_time: string;
|
||
departure_date: string;
|
||
departure_time: string;
|
||
notes: string;
|
||
}
|
||
|
||
export interface ProjectLog {
|
||
_key?: string;
|
||
id?: number;
|
||
project_id: string | number;
|
||
hours: string | number;
|
||
minutes: string | number;
|
||
}
|
||
|
||
export interface Project {
|
||
id: number | string;
|
||
project_number: string;
|
||
name: string;
|
||
}
|
||
|
||
export interface User {
|
||
id: number | string;
|
||
name: string;
|
||
}
|
||
|
||
export interface EditingRecord {
|
||
user_name: string;
|
||
shift_date: string;
|
||
}
|
||
|
||
// ---------- 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: "— 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";
|
||
|
||
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}`,
|
||
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) =>
|
||
updateField("leave_hours", parseFloat(e.target.value))
|
||
}
|
||
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
|
||
key={log._key || 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>
|
||
);
|
||
}
|