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
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
import { useState, useEffect, useRef, useId } from "react";
|
||
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useAlert } from "../context/AlertContext";
|
||
import { useAuth } from "../context/AuthContext";
|
||
import Forbidden from "../components/Forbidden";
|
||
import { motion } from "framer-motion";
|
||
import FormField from "../components/FormField";
|
||
import WarehouseMovementTable, {
|
||
type MovementItem,
|
||
} from "../components/warehouse/WarehouseMovementTable";
|
||
|
||
import { warehouseIssueDetailOptions } from "../lib/queries/warehouse";
|
||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
||
import { useApiMutation } from "../lib/queries/mutations";
|
||
|
||
interface IssueForm {
|
||
project_id: number | null;
|
||
notes: string;
|
||
}
|
||
|
||
export default function WarehouseIssueForm() {
|
||
const { id } = useParams();
|
||
const alert = useAlert();
|
||
const { hasPermission } = useAuth();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
|
||
const baseId = useId();
|
||
const counterRef = useRef(0);
|
||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||
|
||
// Pre-fill data from reservation ("Create výdej" button)
|
||
const prefill = location.state as {
|
||
reservationId?: number;
|
||
itemId?: number;
|
||
projectId?: number;
|
||
quantity?: number;
|
||
itemName?: string;
|
||
} | null;
|
||
|
||
const isEdit = id !== undefined && id !== "new";
|
||
|
||
const [form, setForm] = useState<IssueForm>({
|
||
project_id: prefill?.projectId ?? null,
|
||
notes: "",
|
||
});
|
||
const [items, setItems] = useState<MovementItem[]>(() =>
|
||
isEdit
|
||
? []
|
||
: prefill?.itemId
|
||
? [
|
||
{
|
||
key: nextKey(),
|
||
item_id: prefill.itemId,
|
||
item_name: prefill.itemName,
|
||
quantity: prefill.quantity ?? 0,
|
||
unit_price: 0,
|
||
location_id: null,
|
||
batch_id: null,
|
||
reservation_id: prefill.reservationId ?? null,
|
||
notes: null,
|
||
},
|
||
]
|
||
: [
|
||
{
|
||
key: nextKey(),
|
||
item_id: null,
|
||
quantity: 0,
|
||
unit_price: 0,
|
||
location_id: null,
|
||
batch_id: null,
|
||
reservation_id: null,
|
||
notes: null,
|
||
},
|
||
],
|
||
);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const formInitialized = useRef(false);
|
||
|
||
const { data: issue, isPending } = useQuery(
|
||
warehouseIssueDetailOptions(isEdit ? id : undefined),
|
||
);
|
||
|
||
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
|
||
const projects = projectsData?.data ?? [];
|
||
|
||
useEffect(() => {
|
||
formInitialized.current = false;
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
if (isEdit && issue && !formInitialized.current) {
|
||
setForm({
|
||
project_id: issue.project_id,
|
||
notes: issue.notes || "",
|
||
});
|
||
if (issue.items && issue.items.length > 0) {
|
||
setItems(
|
||
issue.items.map((item) => ({
|
||
key: nextKey(),
|
||
item_id: item.item_id,
|
||
item_name: item.item?.name,
|
||
quantity: Number(item.quantity),
|
||
unit_price: item.batch ? Number(item.batch.unit_price) : 0,
|
||
location_id: item.location_id,
|
||
batch_id: item.batch_id,
|
||
reservation_id: item.reservation_id,
|
||
notes: item.notes,
|
||
})),
|
||
);
|
||
}
|
||
formInitialized.current = true;
|
||
}
|
||
}, [isEdit, issue]);
|
||
|
||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||
|
||
const addItem = () => {
|
||
setItems((prev) => [
|
||
...prev,
|
||
{
|
||
key: nextKey(),
|
||
item_id: null,
|
||
quantity: 0,
|
||
unit_price: 0,
|
||
location_id: null,
|
||
batch_id: null,
|
||
reservation_id: null,
|
||
notes: null,
|
||
},
|
||
]);
|
||
};
|
||
|
||
const validate = (): boolean => {
|
||
const newErrors: Record<string, string> = {};
|
||
if (!form.project_id) {
|
||
newErrors.project_id = "Projekt je povinný";
|
||
}
|
||
const validItems = items.filter((l) => l.item_id !== null);
|
||
if (validItems.length === 0) {
|
||
newErrors.items = "Přidejte alespoň jednu položku";
|
||
}
|
||
for (const item of validItems) {
|
||
if (item.quantity <= 0) {
|
||
newErrors.items = "Množství musí být větší než 0";
|
||
break;
|
||
}
|
||
}
|
||
setErrors(newErrors);
|
||
return Object.keys(newErrors).length === 0;
|
||
};
|
||
|
||
const buildPayload = () => ({
|
||
project_id: form.project_id,
|
||
notes: form.notes.trim() || null,
|
||
items: items
|
||
.filter((l) => l.item_id !== null)
|
||
.map((l) => ({
|
||
item_id: l.item_id!,
|
||
batch_id: l.batch_id,
|
||
quantity: l.quantity,
|
||
location_id: l.location_id,
|
||
reservation_id: l.reservation_id,
|
||
notes: l.notes?.trim() || null,
|
||
})),
|
||
});
|
||
|
||
const saveMutation = useApiMutation<
|
||
ReturnType<typeof buildPayload>,
|
||
{ id?: number }
|
||
>({
|
||
url: () =>
|
||
isEdit
|
||
? `/api/admin/warehouse/issues/${id}`
|
||
: "/api/admin/warehouse/issues",
|
||
method: () => (isEdit ? "PUT" : "POST"),
|
||
invalidate: ["warehouse"],
|
||
});
|
||
|
||
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
|
||
const confirmMutation = useApiMutation<void, void>({
|
||
url: () =>
|
||
confirmIssueId
|
||
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
|
||
: "/api/admin/warehouse/issues/0/confirm",
|
||
method: () => "POST",
|
||
invalidate: ["warehouse"],
|
||
onSuccess: () => {
|
||
alert.success("Výdejka byla potvrzena");
|
||
},
|
||
});
|
||
|
||
const saveIssue = async (): Promise<number | null> => {
|
||
try {
|
||
const data = await saveMutation.mutateAsync(buildPayload());
|
||
return data?.id ?? Number(id);
|
||
} catch (e) {
|
||
alert.error(
|
||
e instanceof Error ? e.message : "Nepodařilo se uložit výdejku",
|
||
);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const confirmIssue = async (issueId: number) => {
|
||
setConfirmIssueId(issueId);
|
||
try {
|
||
await confirmMutation.mutateAsync(undefined);
|
||
} catch (e) {
|
||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||
} finally {
|
||
setConfirmIssueId(null);
|
||
}
|
||
};
|
||
|
||
const handleSaveDraft = async () => {
|
||
if (!validate()) return;
|
||
setSaving(true);
|
||
try {
|
||
const savedId = await saveIssue();
|
||
if (savedId) {
|
||
alert.success("Výdejka byla uložena jako návrh");
|
||
navigate(`/warehouse/issues/${savedId}`);
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveAndConfirm = async () => {
|
||
if (!validate()) return;
|
||
setSaving(true);
|
||
try {
|
||
const savedId = await saveIssue();
|
||
if (savedId) {
|
||
await confirmIssue(savedId);
|
||
navigate(`/warehouse/issues/${savedId}`);
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (isEdit && isPending) {
|
||
return (
|
||
<div className="admin-loading">
|
||
<div className="admin-spinner" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* Header */}
|
||
<motion.div
|
||
className="admin-page-header"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||
<Link
|
||
to="/warehouse/issues"
|
||
className="admin-btn-icon"
|
||
title="Zpět na seznam"
|
||
aria-label="Zpět na seznam"
|
||
>
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
>
|
||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||
</svg>
|
||
</Link>
|
||
<div>
|
||
<h1 className="admin-page-title">
|
||
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
<div className="admin-page-actions">
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveDraft}
|
||
className="admin-btn admin-btn-secondary"
|
||
disabled={saving}
|
||
>
|
||
{saving ? "Ukládání..." : "Uložit jako návrh"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveAndConfirm}
|
||
className="admin-btn admin-btn-primary"
|
||
disabled={saving}
|
||
>
|
||
{saving ? "Ukládání..." : "Uložit a potvrdit"}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* Header fields */}
|
||
<motion.div
|
||
className="admin-card"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25, delay: 0.06 }}
|
||
>
|
||
<div className="admin-card-body">
|
||
<h3 className="admin-card-title">Základní údaje</h3>
|
||
<div className="admin-form">
|
||
<FormField label="Projekt">
|
||
<select
|
||
value={form.project_id ?? ""}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({
|
||
...prev,
|
||
project_id:
|
||
e.target.value === "" ? null : Number(e.target.value),
|
||
}))
|
||
}
|
||
className="admin-form-select"
|
||
>
|
||
<option value="">Vyberte projekt...</option>
|
||
{projects.map((p: Project) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.project_number} – {p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{errors.project_id && (
|
||
<div className="admin-form-error">{errors.project_id}</div>
|
||
)}
|
||
</FormField>
|
||
<FormField label="Poznámky">
|
||
<textarea
|
||
value={form.notes}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, notes: e.target.value }))
|
||
}
|
||
className="admin-form-input"
|
||
rows={3}
|
||
placeholder="Volitelné poznámky"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* Lines */}
|
||
<motion.div
|
||
className="admin-card"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25, delay: 0.08 }}
|
||
>
|
||
<div className="admin-card-body">
|
||
<h3 className="admin-card-title">Položky</h3>
|
||
{errors.items && (
|
||
<div
|
||
className="admin-form-error"
|
||
style={{ marginBottom: "0.75rem" }}
|
||
>
|
||
{errors.items}
|
||
</div>
|
||
)}
|
||
<WarehouseMovementTable
|
||
items={items}
|
||
onChange={setItems}
|
||
mode="issue"
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|