Files
app/src/admin/pages/WarehouseIssueForm.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

383 lines
11 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 { 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>
);
}