From fdc7037e60dbfc7f3ca292d9f1d9e9e9b25f57bd Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 29 May 2026 14:32:11 +0200 Subject: [PATCH] feat(warehouse): add issue pages --- src/admin/pages/WarehouseIssueDetail.tsx | 363 +++++++++++++++++++++++ src/admin/pages/WarehouseIssueForm.tsx | 363 +++++++++++++++++++++++ src/admin/pages/WarehouseIssues.tsx | 337 +++++++++++++++++++++ 3 files changed, 1063 insertions(+) create mode 100644 src/admin/pages/WarehouseIssueDetail.tsx create mode 100644 src/admin/pages/WarehouseIssueForm.tsx create mode 100644 src/admin/pages/WarehouseIssues.tsx diff --git a/src/admin/pages/WarehouseIssueDetail.tsx b/src/admin/pages/WarehouseIssueDetail.tsx new file mode 100644 index 0000000..5d16344 --- /dev/null +++ b/src/admin/pages/WarehouseIssueDetail.tsx @@ -0,0 +1,363 @@ +import { useState } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { useQuery, useQueryClient } 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 ConfirmModal from "../components/ConfirmModal"; +import FormField from "../components/FormField"; + +import apiFetch from "../utils/api"; +import { formatCurrency, formatDate } from "../utils/formatters"; +import { + warehouseIssueDetailOptions, + type WarehouseIssue, +} from "../lib/queries/warehouse"; + +const STATUS_BADGE: Record = { + DRAFT: "admin-badge-warning", + CONFIRMED: "admin-badge-active", + CANCELLED: "admin-badge-danger", +}; + +const STATUS_LABEL: Record = { + DRAFT: "Návrh", + CONFIRMED: "Potvrzeno", + CANCELLED: "Zrušeno", +}; + +export default function WarehouseIssueDetail() { + const { id } = useParams(); + const alert = useAlert(); + const { hasPermission } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [cancelConfirm, setCancelConfirm] = useState(false); + const [confirming, setConfirming] = useState(false); + const [cancelling, setCancelling] = useState(false); + + const { + data: issue, + isPending, + error, + } = useQuery(warehouseIssueDetailOptions(id)); + + if (!hasPermission("warehouse.view")) return ; + + const canOperate = hasPermission("warehouse.operate"); + + const handleConfirm = async () => { + if (!issue) return; + setConfirming(true); + try { + const response = await apiFetch( + `/api/admin/warehouse/issues/${issue.id}/confirm`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] }); + alert.success("Výdejka byla potvrzena"); + } else { + alert.error(result.error || "Nepodařilo se potvrdit výdejku"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setConfirming(false); + } + }; + + const handleCancel = async () => { + if (!issue) return; + setCancelling(true); + try { + const response = await apiFetch( + `/api/admin/warehouse/issues/${issue.id}/cancel`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] }); + setCancelConfirm(false); + alert.success("Výdejka byla zrušena"); + } else { + alert.error(result.error || "Nepodařilo se zrušit výdejku"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setCancelling(false); + } + }; + + if (isPending) { + return ( +
+
+
+ ); + } + + if (error || !issue) { + return ( +
+
+
+ + + + + +
+
+
+
+
+

Výdejka nebyla nalezena.

+
+
+
+
+ ); + } + + const iss = issue as WarehouseIssue; + const lines = iss.lines ?? []; + + return ( +
+ {/* Header */} + +
+ + + + + +
+

+ {iss.issue_number || "Nová výdejka"} +

+ {iss.project && ( +

+ {iss.project.project_number} – {iss.project.name} +

+ )} +
+ + {STATUS_LABEL[iss.status] ?? iss.status} + +
+ {canOperate && ( +
+ {iss.status === "DRAFT" && ( + <> + + + + + )} + {iss.status === "CONFIRMED" && ( + + )} +
+ )} +
+ + {/* Header info */} + +
+

Základní údaje

+
+
+ +
+ {iss.issue_number || "—"} +
+
+ +
+ {iss.project + ? `${iss.project.project_number} – ${iss.project.name}` + : "—"} +
+
+
+
+ +
+ {iss.issued_by_user + ? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}` + : "—"} +
+
+ +
{formatDate(iss.created_at)}
+
+
+ {iss.notes && ( + +
{iss.notes}
+
+ )} +
+
+
+ + {/* Lines table */} + +
+

Řádky výdejky

+ {lines.length === 0 ? ( +
+ Žádné řádky +
+ ) : ( +
+ + + + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + + + + + ))} + +
PoložkaŠaržeMnožstvíCena/ksCelkemLokaceRezervacePoznámka
+ {line.item?.name || `ID: ${line.item_id}`} + + {line.batch + ? `${formatDate(line.batch.received_at)} – ${formatCurrency(Number(line.batch.unit_price), "CZK")}` + : "—"} + + {Number(line.quantity)} + + {line.batch + ? formatCurrency(Number(line.batch.unit_price), "CZK") + : "—"} + + {line.batch + ? formatCurrency( + Number(line.quantity) * + Number(line.batch.unit_price), + "CZK", + ) + : "—"} + + {line.location?.code || "—"} + + {line.reservation + ? `ID: ${line.reservation.id} (${Number(line.reservation.remaining_qty)} ks)` + : "—"} + {line.notes || "—"}
+
+ )} +
+
+ + {/* Cancel confirmation modal */} + setCancelConfirm(false)} + onConfirm={handleCancel} + title="Zrušit výdejku" + message={`Opravdu chcete zrušit výdejku „${iss.issue_number || iss.id}"?`} + confirmText="Zrušit výdejku" + confirmVariant="danger" + loading={cancelling} + /> +
+ ); +} diff --git a/src/admin/pages/WarehouseIssueForm.tsx b/src/admin/pages/WarehouseIssueForm.tsx new file mode 100644 index 0000000..8d2f996 --- /dev/null +++ b/src/admin/pages/WarehouseIssueForm.tsx @@ -0,0 +1,363 @@ +import { useState, useEffect, useRef } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { useQuery, useQueryClient } 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 MovementLine, +} from "../components/warehouse/WarehouseMovementTable"; +import { warehouseLocationListOptions } from "../lib/queries/warehouse"; + +import apiFetch from "../utils/api"; +import { + warehouseIssueDetailOptions, + type WarehouseLocation, +} from "../lib/queries/warehouse"; +import { projectListOptions } from "../lib/queries/projects"; + +interface IssueForm { + project_id: number | null; + notes: string; +} + +let lineCounter = 0; + +export default function WarehouseIssueForm() { + const { id } = useParams(); + const alert = useAlert(); + const { hasPermission } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const isEdit = id !== undefined && id !== "new"; + + const [form, setForm] = useState({ + project_id: null, + notes: "", + }); + const [lines, setLines] = useState([]); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + + const formInitialized = useRef(false); + + const { data: issue, isPending } = useQuery( + warehouseIssueDetailOptions(isEdit ? id : undefined), + ); + + const { data: locations = [] } = useQuery(warehouseLocationListOptions()); + + 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.lines && issue.lines.length > 0) { + setLines( + issue.lines.map((line) => ({ + key: `line-${++lineCounter}`, + item_id: line.item_id, + item_name: line.item?.name, + quantity: Number(line.quantity), + unit_price: line.batch ? Number(line.batch.unit_price) : 0, + location_id: line.location_id, + batch_id: line.batch_id, + reservation_id: line.reservation_id, + notes: line.notes, + })), + ); + } + formInitialized.current = true; + } + }, [isEdit, issue]); + + if (!hasPermission("warehouse.operate")) return ; + + const addEmptyLine = () => { + setLines((prev) => [ + ...prev, + { + key: `line-${++lineCounter}`, + item_id: null, + quantity: 0, + unit_price: 0, + location_id: null, + batch_id: null, + reservation_id: null, + notes: null, + }, + ]); + }; + + // Start with one empty line in create mode + useEffect(() => { + if (!isEdit && lines.length === 0) { + addEmptyLine(); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const validate = (): boolean => { + const newErrors: Record = {}; + if (!form.project_id) { + newErrors.project_id = "Projekt je povinný"; + } + const validLines = lines.filter((l) => l.item_id !== null); + if (validLines.length === 0) { + newErrors.lines = "Přidejte alespoň jednu položku"; + } + for (const line of validLines) { + if (line.quantity <= 0) { + newErrors.lines = "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, + lines: lines + .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 saveIssue = async (): Promise => { + const url = isEdit + ? `/api/admin/warehouse/issues/${id}` + : "/api/admin/warehouse/issues"; + const method = isEdit ? "PUT" : "POST"; + + try { + const response = await apiFetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildPayload()), + }); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] }); + return result.data?.id ?? Number(id); + } else { + alert.error(result.error || "Nepodařilo se uložit výdejku"); + return null; + } + } catch { + alert.error("Chyba připojení"); + return null; + } + }; + + const confirmIssue = async (issueId: number) => { + try { + const response = await apiFetch( + `/api/admin/warehouse/issues/${issueId}/confirm`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] }); + alert.success("Výdejka byla potvrzena"); + } else { + alert.error(result.error || "Nepodařilo se potvrdit výdejku"); + } + } catch { + alert.error("Chyba připojení"); + } + }; + + 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 ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} + +
+ + + + + +
+

+ {isEdit ? "Upravit výdejku" : "Nová výdejka"} +

+
+
+
+ + +
+
+ + {/* Header fields */} + +
+

Základní údaje

+
+ + + {errors.project_id && ( +
+ {errors.project_id} +
+ )} +
+ +