diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index 0ceab7a..99e29a2 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -33,6 +33,7 @@ const OffersCustomers = lazy(() => import("./pages/OffersCustomers")); const OffersTemplates = lazy(() => import("./pages/OffersTemplates")); const Orders = lazy(() => import("./pages/Orders")); const OrderDetail = lazy(() => import("./pages/OrderDetail")); +const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail")); const Projects = lazy(() => import("./pages/Projects")); const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const Invoices = lazy(() => import("./pages/Invoices")); @@ -140,6 +141,14 @@ export default function AdminApp() { element={} /> } /> + } + /> + } + /> } /> } /> } /> diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx new file mode 100644 index 0000000..19d432d --- /dev/null +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -0,0 +1,1287 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { useNavigate, useParams, Link as RouterLink } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import IconButton from "@mui/material/IconButton"; +import CircularProgress from "@mui/material/CircularProgress"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; +import { useApiMutation } from "../lib/queries/mutations"; +import { useAlert } from "../context/AlertContext"; +import { useAuth } from "../context/AuthContext"; +import Forbidden from "../components/Forbidden"; +import RichEditor from "../components/RichEditor"; +import apiFetch from "../utils/api"; +import { jsonQuery } from "../lib/apiAdapter"; +import { offerCustomersOptions, type Customer } from "../lib/queries/offers"; +import { issuedOrderDetailOptions } from "../lib/queries/issued-orders"; +import { formatCurrency, todayLocalStr } from "../utils/formatters"; +import { normalizeDateStr } from "../utils/attendanceHelpers"; +import { + Button, + Card, + TextField, + Select, + DateField, + Field, + StatusChip, + ConfirmDialog, + LoadingState, + PageEnter, + headerActionsSx, +} from "../ui"; + +const API_BASE = "/api/admin"; + +const STATUS_LABELS: Record = { + draft: "Koncept", + sent: "Odeslaná", + confirmed: "Potvrzená", + completed: "Dokončená", + cancelled: "Stornovaná", +}; + +const STATUS_COLORS: Record< + string, + "default" | "success" | "error" | "warning" | "info" +> = { + draft: "default", + sent: "info", + confirmed: "warning", + completed: "success", + cancelled: "error", +}; + +// Labels for the buttons that trigger a status transition. +const TRANSITION_LABELS: Record = { + sent: "Odeslat", + confirmed: "Potvrdit", + completed: "Dokončit", + cancelled: "Stornovat", +}; + +const VAT_OPTIONS = [0, 10, 12, 15, 21].map((v) => ({ + value: String(v), + label: `${v}%`, +})); + +const CURRENCY_OPTIONS = ["CZK", "EUR", "USD", "GBP"].map((c) => ({ + value: c, + label: c, +})); + +const BackIcon = ( + + + +); + +const FileIcon = ( + + + + +); + +const DragIcon = ( + + + + + + + + +); + +const RemoveIcon = ( + + + + +); + +interface OrderItem { + id?: number; + _key: string; + description: string; + item_description: string; + quantity: number; + unit: string; + unit_price: number; + vat_rate: number; +} + +interface OrderForm { + customer_id: number | null; + customer_name: string; + currency: string; + apply_vat: boolean; + vat_rate: number; + order_date: string; + delivery_date: string; + language: string; + delivery_terms: string; + payment_terms: string; + issued_by: string; + notes: string; + internal_notes: string; + status: string; +} + +// Sortable line-item row (mirrors InvoiceDetail's SortableInvoiceRow). +function SortableOrderRow({ + item, + index, + currency, + apply_vat, + readOnly, + onUpdate, + onRemove, + canDelete, +}: { + item: OrderItem; + index: number; + currency: string; + apply_vat: boolean; + readOnly: boolean; + onUpdate: ( + index: number, + field: keyof OrderItem, + value: string | number, + ) => void; + onRemove: (index: number) => void; + canDelete: boolean; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: item._key, disabled: readOnly }); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + background: isDragging ? "var(--mui-palette-action-hover)" : undefined, + position: "relative" as const, + zIndex: isDragging ? 10 : undefined, + }; + const lineTotal = + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + + if (isMobile) { + return ( + + + {!readOnly && ( + + {DragIcon} + + )} + + Položka {index + 1} + + + {!readOnly && canDelete && ( + onRemove(index)} + title="Odebrat" + aria-label="Odebrat" + > + {RemoveIcon} + + )} + + onUpdate(index, "description", e.target.value)} + placeholder="Popis položky..." + InputProps={{ readOnly }} + /> + onUpdate(index, "item_description", e.target.value)} + placeholder="Volitelný" + InputProps={{ readOnly }} + /> + + + onUpdate(index, "quantity", Number(e.target.value)) + } + slotProps={{ htmlInput: { min: "0", step: "any" } }} + InputProps={{ readOnly }} + /> + onUpdate(index, "unit", e.target.value)} + placeholder="ks" + InputProps={{ readOnly }} + /> + + onUpdate(index, "unit_price", Number(e.target.value)) + } + slotProps={{ htmlInput: { step: "any" } }} + InputProps={{ readOnly }} + /> + {apply_vat && + (readOnly ? ( + + ) : ( + onUpdate(index, "vat_rate", Number(val))} + sx={{ minWidth: "4.5rem" }} + options={VAT_OPTIONS} + /> + )} + + ) : null} + + {formatCurrency(lineTotal, currency)} + + + {!readOnly && canDelete && ( + onRemove(index)} + title="Odebrat" + aria-label="Odebrat" + > + {RemoveIcon} + + )} + + + ); +} + +export default function IssuedOrderDetail() { + const { id } = useParams<{ id: string }>(); + const isEdit = Boolean(id); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const navigate = useNavigate(); + const alert = useAlert(); + const { hasPermission, user } = useAuth(); + + const keyCounterRef = useRef(1); + const emptyItem = useCallback( + (): OrderItem => ({ + _key: `io-${++keyCounterRef.current}`, + description: "", + item_description: "", + quantity: 1, + unit: "ks", + unit_price: 0, + vat_rate: 21, + }), + [], + ); + + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor), + ); + + const [form, setForm] = useState({ + customer_id: null, + customer_name: "", + currency: "CZK", + apply_vat: true, + vat_rate: 21, + order_date: todayLocalStr(), + delivery_date: "", + language: "cs", + delivery_terms: "", + payment_terms: "", + issued_by: user?.fullName || "", + notes: "", + internal_notes: "", + status: "draft", + }); + const [items, setItems] = useState([ + { + _key: "io-1", + description: "", + item_description: "", + quantity: 1, + unit: "ks", + unit_price: 0, + vat_rate: 21, + }, + ]); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const [dataReady, setDataReady] = useState(false); + const [poNumber, setPoNumber] = useState(""); + + const [statusChanging, setStatusChanging] = useState(null); + const [statusConfirm, setStatusConfirm] = useState<{ + show: boolean; + status: string | null; + }>({ show: false, status: null }); + const [pdfLoading, setPdfLoading] = useState(false); + + // ─── Queries ─── + const customersQuery = useQuery(offerCustomersOptions()); + const customers = customersQuery.data ?? []; + + const detailQuery = useQuery(issuedOrderDetailOptions(id)); + const detail = detailQuery.data ?? null; + + const nextNumberQuery = useQuery({ + queryKey: ["issued-orders", "next-number"], + queryFn: () => + jsonQuery<{ next_number?: string; number?: string }>( + `${API_BASE}/issued-orders/next-number`, + ).then((d) => d?.next_number || d?.number || ""), + enabled: !isEdit, + }); + + // ─── Edit mode: hydrate form from detail (once) ─── + useEffect(() => { + if (!isEdit || dataReady) return; + if (detailQuery.isLoading || customersQuery.isLoading) return; + if (!detailQuery.data) return; + + const d = detailQuery.data; + setForm({ + customer_id: d.customer_id ?? null, + customer_name: d.customer_name ?? "", + currency: d.currency || "CZK", + apply_vat: d.apply_vat !== false, + vat_rate: Number(d.vat_rate) || 21, + order_date: normalizeDateStr(d.order_date), + delivery_date: normalizeDateStr(d.delivery_date), + language: d.language || "cs", + delivery_terms: d.delivery_terms || "", + payment_terms: d.payment_terms || "", + issued_by: d.issued_by || "", + notes: d.notes || "", + internal_notes: d.internal_notes || "", + status: d.status, + }); + setPoNumber(d.po_number || ""); + + const mapped = + d.items && d.items.length > 0 + ? d.items.map((it) => ({ + _key: `io-${++keyCounterRef.current}`, + id: it.id, + description: it.description || "", + item_description: it.item_description || "", + quantity: Number(it.quantity) || 1, + unit: it.unit || "", + unit_price: Number(it.unit_price) || 0, + vat_rate: Number(it.vat_rate) || Number(d.vat_rate) || 21, + })) + : []; + if (mapped.length > 0) setItems(mapped); + + setDataReady(true); + }, [ + isEdit, + dataReady, + detailQuery.isLoading, + detailQuery.data, + customersQuery.isLoading, + ]); + + // ─── Create mode: set the previewed PO number + default issued_by ─── + useEffect(() => { + if (isEdit || dataReady) return; + if (nextNumberQuery.isLoading || customersQuery.isLoading) return; + if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data); + setDataReady(true); + }, [ + isEdit, + dataReady, + nextNumberQuery.isLoading, + nextNumberQuery.data, + customersQuery.isLoading, + ]); + + // Form is editable only in create mode or while draft/sent. + const editable = !isEdit || form.status === "draft" || form.status === "sent"; + const canExport = hasPermission("orders.export"); + + // ─── Totals (live) ─── + const totals = useMemo(() => { + let subtotal = 0; + const vatByRate: Record = {}; + items.forEach((it) => { + const line = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); + subtotal += line; + if (form.apply_vat) { + const rate = Number(it.vat_rate) || 0; + vatByRate[rate] = (vatByRate[rate] || 0) + (line * rate) / 100; + } + }); + const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0); + return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }; + }, [items, form.apply_vat]); + + // ─── Mutations ─── + const saveMutation = useApiMutation, { id: number }>({ + url: () => + isEdit ? `${API_BASE}/issued-orders/${id}` : `${API_BASE}/issued-orders`, + method: () => (isEdit ? "PUT" : "POST"), + invalidate: ["issued-orders"], + }); + + const statusMutation = useApiMutation<{ status: string }, { id: number }>({ + url: () => `${API_BASE}/issued-orders/${id}`, + method: () => "PUT", + invalidate: ["issued-orders"], + }); + + // ─── Item handlers ─── + const updateItem = ( + index: number, + field: keyof OrderItem, + value: string | number, + ) => + setItems((prev) => + prev.map((it, i) => (i === index ? { ...it, [field]: value } : it)), + ); + + const addItem = () => setItems((prev) => [...prev, emptyItem()]); + + const removeItem = (index: number) => { + if (items.length <= 1) return; + setItems((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setItems((prev) => { + const oldIndex = prev.findIndex((i) => i._key === String(active.id)); + const newIndex = prev.findIndex((i) => i._key === String(over.id)); + if (oldIndex === -1 || newIndex === -1) return prev; + return arrayMove(prev, oldIndex, newIndex); + }); + }; + + const selectCustomer = (val: string) => { + const cid = val ? Number(val) : null; + const c = customers.find((x: Customer) => x.id === cid); + setForm((prev) => ({ + ...prev, + customer_id: cid, + customer_name: c?.name || "", + })); + setErrors((prev) => ({ ...prev, customer_id: "" })); + }; + + // ─── Submit (create + edit) ─── + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + + const newErrors: Record = {}; + if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele"; + if (!form.order_date) newErrors.order_date = "Zadejte datum"; + if (items.length === 0 || items.every((i) => !i.description.trim())) { + newErrors.items = "Přidejte alespoň jednu položku"; + } + setErrors(newErrors); + if (Object.keys(newErrors).length > 0) return; + + setSaving(true); + try { + const payload: Record = { + customer_id: form.customer_id, + currency: form.currency, + vat_rate: form.vat_rate, + apply_vat: form.apply_vat, + order_date: form.order_date, + delivery_date: form.delivery_date || null, + language: form.language, + delivery_terms: form.delivery_terms, + payment_terms: form.payment_terms, + issued_by: form.issued_by, + notes: form.notes, + internal_notes: form.internal_notes, + items: items + .filter((i) => i.description.trim()) + .map((it, i) => ({ + description: it.description, + item_description: it.item_description, + quantity: it.quantity, + unit: it.unit, + unit_price: it.unit_price, + vat_rate: it.vat_rate, + position: i, + })), + }; + + const data = await saveMutation.mutateAsync(payload); + alert.success( + isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena", + ); + if (!isEdit) navigate(`/orders/issued/${data.id}`); + } catch (err) { + alert.error( + err instanceof Error + ? err.message + : isEdit + ? "Nepodařilo se uložit objednávku" + : "Nepodařilo se vytvořit objednávku", + ); + } finally { + setSaving(false); + } + }; + + // ─── Status transition ─── + const handleStatusChange = async () => { + if (!statusConfirm.status) return; + const newStatus = statusConfirm.status; + setStatusChanging(newStatus); + setStatusConfirm({ show: false, status: null }); + try { + await statusMutation.mutateAsync({ status: newStatus }); + alert.success("Stav byl změněn"); + } catch (err) { + alert.error(err instanceof Error ? err.message : "Chyba připojení"); + } finally { + setStatusChanging(null); + } + }; + + // ─── PDF export ─── + const handleViewPdf = async () => { + const newWindow = window.open("", "_blank"); + setPdfLoading(true); + try { + const response = await apiFetch( + `${API_BASE}/issued-orders-pdf/${id}?lang=${form.language}`, + ); + if (!response.ok) { + newWindow?.close(); + alert.error("PDF se nepodařilo vygenerovat"); + return; + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + if (newWindow) newWindow.location.href = url; + setTimeout(() => URL.revokeObjectURL(url), 60000); + } catch { + newWindow?.close(); + alert.error("Chyba připojení"); + } finally { + setPdfLoading(false); + } + }; + + // ─── Permission guards (AFTER all hooks) ─── + if (!isEdit && !hasPermission("orders.create")) return ; + if (isEdit && !hasPermission("orders.view")) return ; + + if (isEdit && !detail && !detailQuery.isError) { + return ; + } + if (!dataReady) { + return ; + } + + return ( + + {/* Header */} + + + + + + {isEdit ? "Objednávka vydaná" : "Nová objednávka vydaná"} + {poNumber && ( + + {poNumber} + + )} + + {isEdit && ( + + )} + + + + {isEdit && canExport && ( + + )} + {editable && ( + + )} + {isEdit && + hasPermission("orders.edit") && + detail?.valid_transitions?.map((status) => ( + + ))} + + + +
+ {/* Basic info */} + + + Základní údaje + + + + + setForm((prev) => ({ ...prev, currency: val })) + } + options={CURRENCY_OPTIONS} + /> + + + + setForm((prev) => ({ ...prev, vat_rate: Number(val) })) + } + options={VAT_OPTIONS} + /> + + + + + setForm((prev) => ({ + ...prev, + apply_vat: e.target.checked, + })) + } + /> + Uplatnit DPH + + + + + + + setForm((prev) => ({ ...prev, issued_by: e.target.value })) + } + placeholder="Jméno" + /> + + + + {/* Items */} + + + + + Položky + + {errors.items && ( + + {errors.items} + + )} + + {editable && ( + + )} + + + + i._key)} + strategy={verticalListSortingStrategy} + > + {isMobile ? ( + + {items.map((item, index) => ( + 1} + /> + ))} + + ) : ( + + + + + + + # + + Popis + + Množství + + + Jednotka + + + Jedn. cena + + {form.apply_vat ? ( + + DPH + + ) : null} + + Celkem + + + + + + {items.map((item, index) => ( + 1} + /> + ))} + +
+
+ )} +
+
+ + {/* Totals */} + + + + Mezisoučet: + + + {formatCurrency(totals.subtotal, form.currency)} + + + {form.apply_vat && + Object.entries(totals.vatByRate).map(([rate, amount]) => ( + + + DPH {rate}%: + + + {formatCurrency(amount, form.currency)} + + + ))} + + + Celkem: + + + {formatCurrency(totals.total, form.currency)} + + + +
+ + {/* Notes & terms */} + + + + setForm((prev) => ({ ...prev, delivery_terms: e.target.value })) + } + placeholder="Např. DAP, sklad odběratele..." + /> + + + + setForm((prev) => ({ ...prev, payment_terms: e.target.value })) + } + placeholder="Např. splatnost 14 dní..." + /> + + + setForm((prev) => ({ ...prev, notes: val }))} + placeholder="Veřejné poznámky zobrazené na objednávce..." + /> + + + + setForm((prev) => ({ ...prev, internal_notes: val })) + } + placeholder="Interní poznámky..." + /> + + +
+ + {/* Status change confirm */} + {isEdit && ( + setStatusConfirm({ show: false, status: null })} + onConfirm={handleStatusChange} + title="Změnit stav objednávky" + message={`Opravdu chcete změnit stav objednávky "${poNumber}" na "${ + STATUS_LABELS[statusConfirm.status || ""] || "" + }"?`} + confirmText={ + TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" + } + cancelText="Zrušit" + confirmVariant={ + statusConfirm.status === "cancelled" ? "danger" : "primary" + } + /> + )} +
+ ); +}