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>
1454 lines
46 KiB
TypeScript
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>
|
|
);
|
|
}
|