Files
app/src/admin/pages/IssuedOrderDetail.tsx
BOHA c270cb22e9 fix(issued-orders): correct status display after create-redirect + per-button loading state
Bug 1: creating an issued order as 'sent' redirected to the detail which showed
'Koncept' until a refresh — the component is reused across create→edit, so the
one-shot hydration (gated by dataReady) never reloaded the real status. Now the
just-saved status is reflected into local form state on every save with a target.

Bug 2: both edit-draft buttons shared one 'saving' flag, so pressing 'Uložit
koncept' made 'Odeslat' show 'Ukládání...'. Track which action is in flight so
only the clicked button spins (all stay disabled to block double-submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:36:55 +02:00

1454 lines
46 KiB
TypeScript

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 { companySettingsOptions } from "../lib/queries/settings";
import { formatCurrency, todayLocalStr } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import {
Button,
Card,
TextField,
Select,
DateField,
Field,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
PageEnter,
CustomerPicker,
headerActionsSx,
} from "../ui";
import {
ISSUED_ORDER_STATUS,
statusLabel,
statusColor,
documentNumberLabel,
} from "../lib/documentStatus";
const API_BASE = "/api/admin";
// The live (finalized) status for an issued order — assigning it (on create or
// on finalizing a draft) makes the backend consume the official PO number.
const LIVE_STATUS = "sent";
// Labels for the buttons that trigger a status transition.
const TRANSITION_LABELS: Record<string, string> = {
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_FALLBACK = ["CZK", "EUR", "USD", "GBP"];
const BackIcon = (
<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>
);
const FileIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
const DragIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
);
const RemoveIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
interface OrderItem {
id?: number;
_key: string;
description: string;
item_description: string;
// Held as the raw typed string while editing so the field can be cleared
// (empty renders fine in a type="number" input). Coerced via Number(x) || 0
// only where used (live totals + save payload). vat_rate stays numeric: it
// is edited via a Select, never a free-text number input.
quantity: string | number;
unit: string;
unit_price: string | 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 (
<Box
ref={setNodeRef}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 1.25,
bgcolor: "background.paper",
...style,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{!readOnly && (
<IconButton
size="small"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
>
{DragIcon}
</IconButton>
)}
<Typography
variant="caption"
sx={{ color: "text.secondary", fontWeight: 700 }}
>
Položka {index + 1}
</Typography>
<Box sx={{ flex: 1 }} />
{!readOnly && canDelete && (
<IconButton
color="error"
size="small"
onClick={() => onRemove(index)}
title="Odebrat"
aria-label="Odebrat"
>
{RemoveIcon}
</IconButton>
)}
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Popis položky..."
InputProps={{ readOnly }}
/>
<TextField
label="Podrobný popis"
value={item.item_description}
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
placeholder="Volitelný"
InputProps={{ readOnly }}
multiline
rows={2}
sx={{ "& textarea": { resize: "vertical" } }}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity ?? ""}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => onUpdate(index, "unit", e.target.value)}
placeholder="ks"
InputProps={{ readOnly }}
/>
<TextField
label="Jedn. cena"
type="number"
value={item.unit_price ?? ""}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
InputProps={{ readOnly }}
/>
{apply_vat &&
(readOnly ? (
<TextField
label="DPH"
value={`${Number(item.vat_rate)}%`}
InputProps={{ readOnly: true }}
/>
) : (
<Select
label="DPH"
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
options={VAT_OPTIONS}
/>
))}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
alignItems: "baseline",
gap: 1,
pt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Celkem
</Typography>
<Typography
sx={{ fontFamily: "'DM Mono', Menlo, monospace", fontWeight: 600 }}
>
{formatCurrency(lineTotal, currency)}
</Typography>
</Box>
</Box>
);
}
return (
<TableRow ref={setNodeRef} sx={style}>
<TableCell sx={{ width: "2rem", px: 0.5 }}>
{!readOnly && (
<IconButton
size="small"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
>
{DragIcon}
</IconButton>
)}
</TableCell>
<TableCell
align="center"
sx={{ color: "text.secondary", fontWeight: 500 }}
>
{index + 1}
</TableCell>
<TableCell sx={{ verticalAlign: "top" }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<TextField
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Popis položky..."
InputProps={{ readOnly }}
sx={{ "& input": { fontWeight: 500 } }}
/>
<TextField
value={item.item_description}
onChange={(e) =>
onUpdate(index, "item_description", e.target.value)
}
placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }}
multiline
rows={2}
sx={{
"& textarea": {
fontSize: "0.8rem",
opacity: 0.8,
resize: "vertical",
},
}}
/>
</Box>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.quantity ?? ""}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }}
/>
</TableCell>
<TableCell>
<TextField
value={item.unit}
onChange={(e) => onUpdate(index, "unit", e.target.value)}
placeholder="ks"
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.unit_price ?? ""}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
{apply_vat ? (
<TableCell>
{readOnly ? (
<Box sx={{ textAlign: "center" }}>{Number(item.vat_rate)}%</Box>
) : (
<Select
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
sx={{ minWidth: "4.5rem" }}
options={VAT_OPTIONS}
/>
)}
</TableCell>
) : null}
<TableCell
align="right"
sx={{
fontWeight: 600,
whiteSpace: "nowrap",
fontFamily: "'DM Mono', Menlo, monospace",
}}
>
{formatCurrency(lineTotal, currency)}
</TableCell>
<TableCell>
{!readOnly && canDelete && (
<IconButton
color="error"
size="small"
onClick={() => onRemove(index)}
title="Odebrat"
aria-label="Odebrat"
>
{RemoveIcon}
</IconButton>
)}
</TableCell>
</TableRow>
);
}
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<OrderForm>({
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<OrderItem[]>([
{
_key: "io-1",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Which save action is in flight ("draft" | the live status | "save"), so
// only the clicked button shows its spinner — `saving` still disables all of
// them to prevent a concurrent double-submit.
const [savingAction, setSavingAction] = useState<string | null>(null);
const [dataReady, setDataReady] = useState(false);
const [poNumber, setPoNumber] = useState("");
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
status: string | null;
}>({ show: false, status: null });
const [pdfLoading, setPdfLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
// ─── Queries ───
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
const companySettings = useQuery(companySettingsOptions()).data;
// Configurable currency list from company settings (falls back to the
// built-in list when settings are empty) — matches Offers/Invoices.
const currencyOptions = (
companySettings?.available_currencies || CURRENCY_FALLBACK
).map((c) => ({ value: c, label: c }));
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,
]);
// Keep the displayed PO number in sync once it becomes available — a draft
// has none until it is finalized (draft → sent), at which point the
// invalidated detail query refetches with the freshly-assigned number. The
// one-shot hydration effect above won't re-run (dataReady stays true), so
// mirror the latest non-empty po_number here.
useEffect(() => {
if (isEdit && detail?.po_number && detail.po_number !== poNumber) {
setPoNumber(detail.po_number);
}
}, [isEdit, detail?.po_number, poNumber]);
// 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<number, number> = {};
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<Record<string, unknown>, { 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"],
});
const deleteMutation = useApiMutation<void, unknown>({
url: () => `${API_BASE}/issued-orders/${id}`,
method: () => "DELETE",
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 = (id: number | null) => {
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
setForm((prev) => ({
...prev,
customer_id: id,
customer_name: c?.name || "",
}));
setErrors((prev) => ({ ...prev, customer_id: "" }));
};
// ─── Submit (create + edit) ───
const handleSubmit = async (targetStatus?: string) => {
const newErrors: Record<string, string> = {};
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);
setSavingAction(targetStatus ?? "save");
try {
const payload: Record<string, unknown> = {
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,
// Coerce the raw typed strings back to numbers so an empty/partial
// field never reaches the server as NaN (mirrors the totals math).
quantity: Number(it.quantity) || 0,
unit: it.unit,
unit_price: Number(it.unit_price) || 0,
vat_rate: it.vat_rate,
position: i,
})),
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live order sends no status — the backend keeps
// its current status. The PO number is never user-editable.
if (targetStatus) payload.status = targetStatus;
const data = await saveMutation.mutateAsync(payload);
alert.success(
isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena",
);
// Reflect the just-saved status locally. On CREATE the component is reused
// for the redirect to the new order's detail and the one-shot edit-mode
// hydration won't re-run (dataReady stays true), so without this the page
// would show the stale create-mode status ("draft") even though the order
// was created as "sent" — until a manual refresh. On finalize (edit
// draft→live) it flips the form out of the draft button branch. The PO
// number is repopulated by the detail-query sync effect.
if (targetStatus) {
setForm((prev) => ({ ...prev, status: targetStatus }));
}
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);
setSavingAction(null);
}
};
// Native form submit (e.g. Enter): route to the right save path. Create →
// finalize live; editing a draft → save draft (the visible primary button);
// editing a live order → plain save (no status change).
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!isEdit) void handleSubmit(LIVE_STATUS);
else if (form.status === "draft") void handleSubmit("draft");
else void handleSubmit();
};
// ─── 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);
}
};
// ─── Delete ───
const handleDelete = async () => {
setDeleting(true);
try {
await deleteMutation.mutateAsync(undefined);
alert.success("Objednávka byla smazána");
navigate("/orders?tab=vydane");
} catch (err) {
alert.error(err instanceof Error ? err.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
}
};
// ─── 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 <Forbidden />;
if (isEdit && !hasPermission("orders.view")) return <Forbidden />;
if (isEdit && !detail && !detailQuery.isError) {
return <LoadingState />;
}
if (!dataReady) {
return <LoadingState />;
}
return (
<PageEnter>
{/* Header */}
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Button
component={RouterLink}
to="/orders?tab=vydane"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
{isEdit ? "Objednávka vydaná" : "Nová objednávka vydaná"}
{/* Edit mode: a draft has no PO number yet → show "Koncept".
Create mode: show the previewed next-number when available. */}
{(isEdit || poNumber) && (
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{isEdit ? documentNumberLabel(poNumber) : poNumber}
</Box>
)}
</Typography>
{isEdit && (
<StatusChip
label={statusLabel(ISSUED_ORDER_STATUS, form.status)}
color={statusColor(ISSUED_ORDER_STATUS, form.status)}
/>
)}
</Box>
</Box>
<Box sx={headerActionsSx}>
{isEdit && canExport && (
<Button
onClick={handleViewPdf}
variant="outlined"
color="inherit"
disabled={pdfLoading}
startIcon={
pdfLoading ? (
<CircularProgress size={16} color="inherit" />
) : (
FileIcon
)
}
>
Export PDF
</Button>
)}
{/* ── Create mode: two-button save ── */}
{!isEdit && (
<>
<Button
variant="outlined"
color="inherit"
onClick={() => handleSubmit("draft")}
disabled={saving}
>
{savingAction === "draft" ? (
<CircularProgress size={16} color="inherit" />
) : (
"Uložit koncept"
)}
</Button>
<Button
onClick={() => handleSubmit(LIVE_STATUS)}
disabled={saving}
>
{savingAction === LIVE_STATUS ? (
<>
<CircularProgress
size={16}
color="inherit"
sx={{ mr: 1 }}
/>
Ukládání...
</>
) : (
"Vytvořit"
)}
</Button>
</>
)}
{/* ── Edit mode, draft: save-draft + finalize (Odeslat). A draft is
deleted rather than cancelled, so the generic transition buttons
(sent/cancelled) are suppressed — the finalize button IS the
draft → sent transition. ── */}
{isEdit && form.status === "draft" && (
<>
<Button
variant="outlined"
color="inherit"
onClick={() => handleSubmit("draft")}
disabled={saving}
>
{savingAction === "draft" ? (
<CircularProgress size={16} color="inherit" />
) : (
"Uložit koncept"
)}
</Button>
{hasPermission("orders.edit") && (
<Button
onClick={() => handleSubmit(LIVE_STATUS)}
disabled={saving}
>
{savingAction === LIVE_STATUS ? (
<>
<CircularProgress
size={16}
color="inherit"
sx={{ mr: 1 }}
/>
Ukládání...
</>
) : (
"Odeslat"
)}
</Button>
)}
</>
)}
{/* ── Edit mode, live (non-draft) order — unchanged save + transitions ── */}
{isEdit && form.status !== "draft" && (
<>
{editable && (
<Button onClick={() => handleSubmit()} disabled={saving}>
{savingAction === "save" ? (
<>
<CircularProgress
size={16}
color="inherit"
sx={{ mr: 1 }}
/>
Ukládání...
</>
) : (
"Uložit"
)}
</Button>
)}
{hasPermission("orders.edit") &&
detail?.valid_transitions?.map((status) => (
<Button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
variant={status === "cancelled" ? "outlined" : "contained"}
color={status === "cancelled" ? "error" : "primary"}
disabled={statusChanging === status}
>
{statusChanging === status ? (
<CircularProgress size={14} color="inherit" />
) : (
TRANSITION_LABELS[status] || status
)}
</Button>
))}
</>
)}
{/* A completed order is terminal/finalized — never deletable from the
UI. draft/sent/confirmed/cancelled may be deleted. (Backend
rejection of deleting an order is a separate hardening, out of
scope here.) */}
{isEdit &&
hasPermission("orders.delete") &&
form.status !== "completed" && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</Box>
</Box>
<form onSubmit={handleFormSubmit}>
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Dodavatel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
disabled={!editable}
error={errors.customer_id}
placeholder="Vyberte dodavatele…"
/>
</Field>
<Field label="Datum objednávky" error={errors.order_date} required>
<DateField
value={form.order_date}
disabled={!editable}
onChange={(val) => {
setForm((prev) => ({ ...prev, order_date: val }));
setErrors((prev) => ({ ...prev, order_date: "" }));
}}
/>
</Field>
<Field label="Datum dodání">
<DateField
value={form.delivery_date}
disabled={!editable}
onChange={(val) =>
setForm((prev) => ({ ...prev, delivery_date: val }))
}
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Měna">
<Select
value={form.currency}
disabled={!editable}
onChange={(val) =>
setForm((prev) => ({ ...prev, currency: val }))
}
options={currencyOptions}
/>
</Field>
<Field label="Jazyk">
<Select
value={form.language}
disabled={!editable}
onChange={(val) =>
setForm((prev) => ({ ...prev, language: val }))
}
options={[
{ value: "cs", label: "Čeština" },
{ value: "en", label: "English" },
]}
/>
</Field>
<Field label="Sazba DPH">
<Select
value={String(form.vat_rate)}
disabled={!editable || !form.apply_vat}
onChange={(val) =>
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
}
options={VAT_OPTIONS}
/>
</Field>
<Field label="DPH">
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
<CheckboxField
label="Uplatnit DPH"
checked={form.apply_vat}
disabled={!editable}
onChange={(v) =>
setForm((prev) => ({
...prev,
apply_vat: v,
}))
}
/>
</Box>
</Field>
</Box>
<Field label="Vystavil">
<TextField
value={form.issued_by}
InputProps={{ readOnly: true }}
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
/>
</Field>
</Card>
{/* Items */}
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 2,
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography variant="caption" color="error">
{errors.items}
</Typography>
)}
</Box>
{editable && (
<Button
type="button"
onClick={addItem}
variant="outlined"
color="inherit"
size="small"
>
+ Přidat položku
</Button>
)}
</Box>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
{isMobile ? (
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{items.map((item, index) => (
<SortableOrderRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={form.apply_vat}
readOnly={!editable}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2rem" }} />
<TableCell sx={{ width: "2rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Množství
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jednotka
</TableCell>
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH
</TableCell>
) : null}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
<TableCell sx={{ width: "2.5rem" }} />
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableOrderRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={form.apply_vat}
readOnly={!editable}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</TableBody>
</Table>
</TableContainer>
)}
</SortableContext>
</DndContext>
{/* Totals */}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.subtotal, form.currency)}
</Typography>
</Box>
{form.apply_vat &&
Object.entries(totals.vatByRate).map(([rate, amount]) => (
<Box
key={rate}
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography variant="body2" color="text.secondary">
DPH {rate}%:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(amount, form.currency)}
</Typography>
</Box>
))}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem:
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
>
{formatCurrency(totals.total, form.currency)}
</Typography>
</Box>
</Box>
</Card>
{/* Notes & terms */}
<Card sx={{ mb: 3 }}>
<Field label="Dodací podmínky">
<TextField
value={form.delivery_terms}
disabled={!editable}
onChange={(e) =>
setForm((prev) => ({ ...prev, delivery_terms: e.target.value }))
}
placeholder="Např. DAP, sklad odběratele..."
/>
</Field>
<Field label="Platební podmínky">
<TextField
value={form.payment_terms}
disabled={!editable}
onChange={(e) =>
setForm((prev) => ({ ...prev, payment_terms: e.target.value }))
}
placeholder="Např. splatnost 14 dní..."
/>
</Field>
<Field label="Poznámky (na PDF)">
<RichEditor
value={form.notes}
readOnly={!editable}
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
placeholder="Veřejné poznámky zobrazené na objednávce..."
/>
</Field>
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
<RichEditor
value={form.internal_notes}
readOnly={!editable}
onChange={(val) =>
setForm((prev) => ({ ...prev, internal_notes: val }))
}
placeholder="Interní poznámky..."
/>
</Field>
</Card>
</form>
{/* Status change confirm */}
{isEdit && (
<ConfirmDialog
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${poNumber}" na "${statusLabel(
ISSUED_ORDER_STATUS,
statusConfirm.status,
)}"?`}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
cancelText="Zrušit"
confirmVariant={
statusConfirm.status === "cancelled" ? "danger" : "primary"
}
/>
)}
{/* Delete confirm */}
{isEdit && (
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat objednávku?"
message={`Opravdu chcete smazat objednávku "${poNumber}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
)}
</PageEnter>
);
}