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
523 lines
17 KiB
TypeScript
523 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|