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 ? (
+
+ ) : (
+
+
+
+ Celkem
+
+
+ {formatCurrency(lineTotal, currency)}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {!readOnly && (
+
+ {DragIcon}
+
+ )}
+
+
+ {index + 1}
+
+
+
+ onUpdate(index, "description", e.target.value)}
+ placeholder="Popis položky..."
+ InputProps={{ readOnly }}
+ sx={{ "& input": { fontWeight: 500 } }}
+ />
+
+ onUpdate(index, "item_description", e.target.value)
+ }
+ placeholder="Podrobný popis (volitelný)"
+ InputProps={{ readOnly }}
+ sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }}
+ />
+
+
+
+ onUpdate(index, "quantity", Number(e.target.value))}
+ slotProps={{ htmlInput: { min: "0", step: "any" } }}
+ InputProps={{ readOnly }}
+ sx={{ "& input": { textAlign: "center" } }}
+ />
+
+
+ onUpdate(index, "unit", e.target.value)}
+ placeholder="ks"
+ InputProps={{ readOnly }}
+ sx={{ "& input": { textAlign: "center" } }}
+ />
+
+
+
+ onUpdate(index, "unit_price", Number(e.target.value))
+ }
+ slotProps={{ htmlInput: { step: "any" } }}
+ InputProps={{ readOnly }}
+ sx={{ "& input": { textAlign: "right" } }}
+ />
+
+ {apply_vat ? (
+
+ {readOnly ? (
+ {Number(item.vat_rate)}%
+ ) : (
+
+ ) : 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 && (
+
+ ) : (
+ FileIcon
+ )
+ }
+ >
+ Export PDF
+
+ )}
+ {editable && (
+
+ )}
+ {isEdit &&
+ hasPermission("orders.edit") &&
+ detail?.valid_transitions?.map((status) => (
+
+ ))}
+
+
+
+
+
+ {/* 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"
+ }
+ />
+ )}
+
+ );
+}