Files
app/src/admin/components/ShiftFormModal.tsx
BOHA 5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00

523 lines
17 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 { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
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;
}
// ---------- 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 (
<div
style={{
padding: "0.5rem 0.75rem",
marginBottom: "0.5rem",
borderRadius: "6px",
fontSize: "0.8rem",
background: isMatch
? "var(--success-bg, rgba(34,197,94,0.1))"
: "var(--danger-bg, rgba(239,68,68,0.1))",
color: isMatch
? "var(--success-color, #16a34a)"
: "var(--danger-color, #dc2626)",
border: `1px solid ${
isMatch
? "var(--success-border, rgba(34,197,94,0.3))"
: "var(--danger-border, rgba(239,68,68,0.3))"
}`,
}}
>
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)" : ""}
</div>
);
}
// ---------- ProjectLogRow ----------
function ProjectLogRow({
log,
index,
projectList,
onUpdate,
onRemove,
}: ProjectLogRowProps) {
return (
<div className="flex-row gap-2 mb-2">
<select
value={log.project_id}
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
className="admin-form-select"
style={{ flex: 3, marginBottom: 0 }}
>
<option value=""> Projekt </option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
<input
type="number"
min="0"
max="24"
value={log.hours}
onChange={(e) => onUpdate(index, "hours", e.target.value)}
className="admin-form-input"
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="h"
/>
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
h
</span>
<input
type="number"
min="0"
max="59"
value={log.minutes}
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
className="admin-form-input"
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="m"
/>
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
m
</span>
<button
type="button"
onClick={() => onRemove(index)}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ padding: "0.375rem", flexShrink: 0 }}
title="Odebrat"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
);
}
// ---------- ShiftFormModal ----------
export default function ShiftFormModal({
mode,
isOpen,
onClose,
onSubmit,
form,
setForm,
projectLogs,
setProjectLogs,
projectList,
users,
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(isOpen);
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 (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form">
{isCreate ? (
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">
Zaměstnanec
</label>
<select
value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)}
className="admin-form-select"
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">
Datum směny
</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => updateField("shift_date", val)}
/>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Typ záznamu</label>
<select
value={form.leave_type}
onChange={(e) => updateField("leave_type", e.target.value)}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>
{!isWorkType && (
<div className="admin-form-group">
<label className="admin-form-label">Počet hodin</label>
<input
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField("leave_hours", parseFloat(e.target.value))
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">
8 hodin = celý den
</small>
)}
</div>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - čas
</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField("break_start_date", val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
updateField("break_start_time", val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod - čas</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</div>
</div>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<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
type="button"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}