diff --git a/src/admin/pages/WarehouseReceiptDetail.tsx b/src/admin/pages/WarehouseReceiptDetail.tsx new file mode 100644 index 0000000..74d5071 --- /dev/null +++ b/src/admin/pages/WarehouseReceiptDetail.tsx @@ -0,0 +1,460 @@ +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 { + warehouseReceiptDetailOptions, + type WarehouseReceipt, +} 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", +}; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function WarehouseReceiptDetail() { + 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: receipt, + isPending, + error, + } = useQuery(warehouseReceiptDetailOptions(id)); + + if (!hasPermission("warehouse.view")) return ; + + const canOperate = hasPermission("warehouse.operate"); + + const handleConfirm = async () => { + if (!receipt) return; + setConfirming(true); + try { + const response = await apiFetch( + `/api/admin/warehouse/receipts/${receipt.id}/confirm`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] }); + alert.success("Doklad byl potvrzen"); + } else { + alert.error(result.error || "Nepodařilo se potvrdit doklad"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setConfirming(false); + } + }; + + const handleCancel = async () => { + if (!receipt) return; + setCancelling(true); + try { + const response = await apiFetch( + `/api/admin/warehouse/receipts/${receipt.id}/cancel`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] }); + setCancelConfirm(false); + alert.success("Doklad byl zrušen"); + } else { + alert.error(result.error || "Nepodařilo se zrušit doklad"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setCancelling(false); + } + }; + + const handleDeleteAttachment = async (attachmentId: number) => { + if (!receipt) return; + try { + const response = await apiFetch( + `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`, + { method: "DELETE" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] }); + alert.success("Příloha byla smazána"); + } else { + alert.error(result.error || "Nepodařilo se smazat přílohu"); + } + } catch { + alert.error("Chyba připojení"); + } + }; + + if (isPending) { + return ( +
+
+
+ ); + } + + if (error || !receipt) { + return ( +
+
+
+ + + + + +
+
+
+
+
+

Doklad nebyl nalezen.

+
+
+
+
+ ); + } + + const r = receipt as WarehouseReceipt; + const lines = r.lines ?? []; + const attachments = r.attachments ?? []; + + return ( +
+ {/* Header */} + +
+ + + + + +
+

+ {r.receipt_number || "Nový doklad"} +

+ {r.supplier?.name && ( +

{r.supplier.name}

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

Základní údaje

+
+
+ +
+ {r.receipt_number || "—"} +
+
+ +
{r.supplier?.name || "—"}
+
+
+
+ +
+ {r.delivery_note_number || "—"} +
+
+ +
+ {formatDate(r.delivery_note_date)} +
+
+
+
+ +
+ {r.received_by_user + ? `${r.received_by_user.first_name} ${r.received_by_user.last_name}` + : "—"} +
+
+ +
{formatDate(r.created_at)}
+
+
+ {r.notes && ( + +
{r.notes}
+
+ )} +
+
+
+ + {/* Lines table */} + +
+

Řádky dokladu

+ {lines.length === 0 ? ( +
+ Žádné řádky +
+ ) : ( +
+ + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + + + ))} + +
PoložkaMnožstvíCena/ksCelkemLokacePoznámka
+ {line.item?.name || `ID: ${line.item_id}`} + + {Number(line.quantity)} + + {formatCurrency(Number(line.unit_price), "CZK")} + + {formatCurrency( + Number(line.quantity) * Number(line.unit_price), + "CZK", + )} + + {line.location?.code || "—"} + {line.notes || "—"}
+
+ )} +
+
+ + {/* Attachments */} + +
+

Přílohy

+ {attachments.length === 0 ? ( +
+ Žádné přílohy +
+ ) : ( +
+ + + + + + + + + + + {attachments.map((att) => ( + + + + + + + ))} + +
Název souboruVelikostDatum
+ + {att.file_name} + + + {formatFileSize(att.file_size)} + + {formatDate(att.created_at)} + + {r.status === "DRAFT" && canOperate && ( + + )} +
+
+ )} +
+
+ + {/* Cancel confirmation modal */} + setCancelConfirm(false)} + onConfirm={handleCancel} + title="Zrušit doklad" + message={`Opravdu chcete zrušit příjmový doklad „${r.receipt_number || r.id}"?`} + confirmText="Zrušit doklad" + confirmVariant="danger" + loading={cancelling} + /> +
+ ); +} diff --git a/src/admin/pages/WarehouseReceiptForm.tsx b/src/admin/pages/WarehouseReceiptForm.tsx new file mode 100644 index 0000000..81702ba --- /dev/null +++ b/src/admin/pages/WarehouseReceiptForm.tsx @@ -0,0 +1,499 @@ +import { useState, useEffect, useRef, useCallback } 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 SupplierSelect from "../components/warehouse/SupplierSelect"; +import WarehouseMovementTable, { + type MovementLine, +} from "../components/warehouse/WarehouseMovementTable"; +import { warehouseLocationListOptions } from "../lib/queries/warehouse"; + +import apiFetch from "../utils/api"; +import { + warehouseReceiptDetailOptions, + type WarehouseLocation, +} from "../lib/queries/warehouse"; + +interface ReceiptForm { + supplier_id: number | null; + delivery_note_number: string; + delivery_note_date: string; + notes: string; +} + +let lineCounter = 0; + +export default function WarehouseReceiptForm() { + 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({ + supplier_id: null, + delivery_note_number: "", + delivery_note_date: "", + notes: "", + }); + const [lines, setLines] = useState([]); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const [uploadingFiles, setUploadingFiles] = useState(false); + + const formInitialized = useRef(false); + + const { data: receipt, isPending } = useQuery( + warehouseReceiptDetailOptions(isEdit ? id : undefined), + ); + + const { data: locations = [] } = useQuery(warehouseLocationListOptions()); + + useEffect(() => { + formInitialized.current = false; + }, [id]); + + useEffect(() => { + if (isEdit && receipt && !formInitialized.current) { + setForm({ + supplier_id: receipt.supplier_id, + delivery_note_number: receipt.delivery_note_number || "", + delivery_note_date: receipt.delivery_note_date + ? receipt.delivery_note_date.split("T")[0] + : "", + notes: receipt.notes || "", + }); + if (receipt.lines && receipt.lines.length > 0) { + setLines( + receipt.lines.map((line) => ({ + key: `line-${++lineCounter}`, + item_id: line.item_id, + item_name: line.item?.name, + quantity: Number(line.quantity), + unit_price: Number(line.unit_price), + location_id: line.location_id, + batch_id: null, + reservation_id: null, + notes: line.notes, + })), + ); + } + formInitialized.current = true; + } + }, [isEdit, receipt]); + + 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 = {}; + 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 = () => ({ + supplier_id: form.supplier_id, + delivery_note_number: form.delivery_note_number.trim() || null, + delivery_note_date: form.delivery_note_date || null, + notes: form.notes.trim() || null, + lines: lines + .filter((l) => l.item_id !== null) + .map((l) => ({ + item_id: l.item_id!, + quantity: l.quantity, + unit_price: l.unit_price, + location_id: l.location_id, + notes: l.notes?.trim() || null, + })), + }); + + const saveReceipt = async (): Promise => { + const url = isEdit + ? `/api/admin/warehouse/receipts/${id}` + : "/api/admin/warehouse/receipts"; + 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", "receipts"] }); + return result.data?.id ?? Number(id); + } else { + alert.error(result.error || "Nepodařilo se uložit doklad"); + return null; + } + } catch { + alert.error("Chyba připojení"); + return null; + } + }; + + const confirmReceipt = async (receiptId: number) => { + try { + const response = await apiFetch( + `/api/admin/warehouse/receipts/${receiptId}/confirm`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] }); + alert.success("Doklad byl potvrzen"); + } else { + alert.error(result.error || "Nepodařilo se potvrdit doklad"); + } + } catch { + alert.error("Chyba připojení"); + } + }; + + const handleSaveDraft = async () => { + if (!validate()) return; + setSaving(true); + try { + const savedId = await saveReceipt(); + if (savedId) { + alert.success("Doklad byl uložen jako návrh"); + navigate(`/warehouse/receipts/${savedId}`); + } + } finally { + setSaving(false); + } + }; + + const handleSaveAndConfirm = async () => { + if (!validate()) return; + setSaving(true); + try { + const savedId = await saveReceipt(); + if (savedId) { + await confirmReceipt(savedId); + navigate(`/warehouse/receipts/${savedId}`); + } + } finally { + setSaving(false); + } + }; + + const handleFileUpload = useCallback( + async (files: FileList | File[]) => { + if (!isEdit || !id) return; + setUploadingFiles(true); + try { + for (const file of files) { + const formData = new FormData(); + formData.append("file", file); + const response = await apiFetch( + `/api/admin/warehouse/receipts/${id}/attachments`, + { + method: "POST", + body: formData, + }, + ); + const result = await response.json(); + if (!result.success) { + alert.error( + result.error || `Nepodařilo se nahrát soubor ${file.name}`, + ); + } + } + queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] }); + alert.success("Soubory byly nahrány"); + } catch { + alert.error("Chyba připojení"); + } finally { + setUploadingFiles(false); + } + }, + [id, isEdit, alert, queryClient], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.files.length > 0) { + handleFileUpload(e.dataTransfer.files); + } + }, + [handleFileUpload], + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + if (isEdit && isPending) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} + +
+ + + + + +
+

+ {isEdit ? "Upravit příjmový doklad" : "Nový příjmový doklad"} +

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

Základní údaje

+
+ + + setForm((prev) => ({ ...prev, supplier_id: val })) + } + /> + +
+ + + setForm((prev) => ({ + ...prev, + delivery_note_number: e.target.value, + })) + } + className="admin-form-input" + placeholder="Např. DL-2024-001" + /> + + + + setForm((prev) => ({ + ...prev, + delivery_note_date: e.target.value, + })) + } + className="admin-form-input" + /> + +
+ +