Files
app/src/admin/pages/OfferDetail.tsx
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

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

2060 lines
66 KiB
TypeScript

import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ChangeEvent,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
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 Checkbox from "@mui/material/Checkbox";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import {
offerDetailOptions,
offerCustomersOptions,
scopeTemplatesOptions,
offerNextNumberOptions,
itemTemplatesOptions,
type ItemTemplate,
type OfferOrderInfo,
type Customer,
} from "../lib/queries/offers";
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 Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import useDebounce from "../hooks/useDebounce";
import apiFetch from "../utils/api";
import { formatCurrency, todayLocalStr } from "../utils/formatters";
import { companySettingsOptions } from "../lib/queries/settings";
import {
Button,
Card,
TextField,
Select,
DateField,
Field,
StatusChip,
Modal,
ConfirmDialog,
EmptyState,
LoadingState,
PageEnter,
headerActionsSx,
} from "../ui";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
interface OfferItem {
_key: string;
id?: number;
description: string;
item_description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
}
interface ScopeSection {
title: string;
title_cz: string;
content: string;
}
interface OfferForm {
quotation_number: string;
project_code: string;
customer_id: number | null;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
}
const emptyForm: OfferForm = {
quotation_number: "",
project_code: "",
customer_id: null,
customer_name: "",
created_at: todayLocalStr(),
valid_until: "",
currency: "CZK",
language: "EN",
vat_rate: 21,
apply_vat: false,
};
const emptyScopeSection = (): ScopeSection => ({
title: "",
title_cz: "",
content: "",
});
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="16"
height="16"
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>
);
const UpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
);
const DownIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
function SortableItemRow({
item,
index,
currency,
readOnly,
canDelete,
onUpdate,
onRemove,
}: {
item: OfferItem;
index: number;
currency: string;
readOnly: boolean;
canDelete: boolean;
onUpdate: (field: string, value: unknown) => void;
onRemove: () => void;
}) {
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 && (
<IconButton
color="error"
size="small"
onClick={onRemove}
title="Odebrat"
aria-label="Odebrat"
disabled={!canDelete}
>
{RemoveIcon}
</IconButton>
)}
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) => onUpdate("description", e.target.value)}
placeholder="Název položky"
InputProps={{ readOnly }}
/>
<TextField
label="Podrobný popis"
value={item.item_description}
onChange={(e) => onUpdate("item_description", e.target.value)}
placeholder="Volitelný"
InputProps={{ readOnly }}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => onUpdate("unit", e.target.value)}
InputProps={{ readOnly }}
/>
<TextField
label="Cena/ks"
type="number"
value={item.unit_price}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }}
/>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box
component="label"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
cursor: readOnly ? "default" : "pointer",
}}
>
<Checkbox
checked={item.is_included_in_total}
onChange={(e) =>
onUpdate("is_included_in_total", e.target.checked)
}
disabled={readOnly}
/>
<Typography variant="body2">V ceně</Typography>
</Box>
<Box sx={{ textAlign: "right" }}>
<Typography
variant="caption"
sx={{ color: "text.secondary", display: "block" }}
>
Celkem
</Typography>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
}}
>
{formatCurrency(lineTotal, currency)}
</Typography>
</Box>
</Box>
</Box>
);
}
return (
<TableRow ref={setNodeRef} sx={style}>
{!readOnly && (
<TableCell sx={{ width: "2rem", px: 0.5 }}>
<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" }}>
{index + 1}
</TableCell>
<TableCell sx={{ verticalAlign: "top" }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<TextField
value={item.description}
onChange={(e) => onUpdate("description", e.target.value)}
placeholder="Název položky"
InputProps={{ readOnly }}
sx={{ "& input": { fontWeight: 500 } }}
/>
<TextField
value={item.item_description}
onChange={(e) => onUpdate("item_description", e.target.value)}
placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }}
sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }}
/>
</Box>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.quantity}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }}
/>
</TableCell>
<TableCell>
<TextField
value={item.unit}
onChange={(e) => onUpdate("unit", e.target.value)}
InputProps={{ readOnly }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.unit_price}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }}
/>
</TableCell>
<TableCell align="center">
<Checkbox
checked={item.is_included_in_total}
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
disabled={readOnly}
/>
</TableCell>
<TableCell
align="right"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", fontWeight: 600 }}
>
{formatCurrency(lineTotal, currency)}
</TableCell>
{!readOnly && (
<TableCell>
<IconButton
color="error"
size="small"
onClick={onRemove}
title="Odebrat"
aria-label="Odebrat"
disabled={!canDelete}
>
{RemoveIcon}
</IconButton>
</TableCell>
)}
</TableRow>
);
}
function loadOfferDraft(): {
form?: Record<string, unknown>;
items?: unknown[];
sections?: unknown[];
} | null {
try {
const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.error("Failed to load offer draft:", e);
return null;
}
}
export default function OfferDetail() {
const { id } = useParams();
const isEdit = Boolean(id);
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
}),
useSensor(KeyboardSensor),
);
const itemKeyCounter = useRef(0);
const emptyItem = useCallback(
(): OfferItem => ({
_key: `item-${++itemKeyCounter.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
}),
[],
);
const queryClient = useQueryClient();
// ---- TanStack Query hooks ----
const offerQuery = useQuery(offerDetailOptions(id));
const { data: customersData } = useQuery({
...offerCustomersOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery(scopeTemplatesOptions());
const { data: itemTemplates } = useQuery(itemTemplatesOptions());
const { data: nextNumberData } = useQuery({
...offerNextNumberOptions(),
enabled: !isEdit,
});
const customers: Customer[] = Array.isArray(customersData)
? customersData
: [];
const scopeTemplates = templatesData ?? [];
const loading = isEdit && offerQuery.isLoading;
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(() => {
// The draft is the unsaved-NEW-offer buffer — never restore it over an
// existing offer in edit mode (it would also leak its stale item keys).
const draft = isEdit ? null : loadOfferDraft();
if (draft?.form) {
return {
quotation_number:
(draft.form.quotation_number as string) || emptyForm.quotation_number,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
language: (draft.form.language as string) || emptyForm.language,
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
};
}
return emptyForm;
});
const [items, setItems] = useState<OfferItem[]>(() => {
const draft = isEdit ? null : loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) {
// Re-key restored items: their persisted _key values are stale and the
// counter restarts at 0 each load, so reusing them would collide with
// newly-added rows (duplicate React key / dnd-kit id → the warning).
return (draft.items as OfferItem[]).map((it) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}));
}
return [emptyItem()];
});
const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = isEdit ? null : loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[];
}
return [];
});
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [creatingOrder, setCreatingOrder] = useState(false);
const [showOrderModal, setShowOrderModal] = useState(false);
const [invalidateConfirm, setInvalidateConfirm] = useState(false);
const [invalidatingOffer, setInvalidatingOffer] = useState(false);
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const { data: companySettings } = useQuery(companySettingsOptions());
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const [lockedBy, setLockedBy] = useState<{
user_id: number;
username: string;
full_name: string;
} | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
// Set to true right after a successful delete so the detail-page error
// effect doesn't fire "Nepodařilo se načíst nabídku" on top of the
// success toast (the 404 from a refetched-already-deleted row is
// expected here, not a real error).
const deletingRef = useRef(false);
// Note: Modal applies useDialogScrollLock internally.
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const isInvalidated = offerStatus === "invalidated";
const isCompleted = orderInfo?.status === "dokoncena";
const isLockedByOther = !!lockedBy;
const readOnly = isInvalidated || isLockedByOther || isCompleted;
const canInvalidate = isEdit && !isInvalidated && !isCompleted && !orderInfo;
// Sync offer detail data to form state on first load (edit mode)
const formInitializedRef = useRef(false);
useEffect(() => {
if (!offerQuery.data || formInitializedRef.current) return;
const d = offerQuery.data;
const formData: OfferForm = {
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id ?? null,
customer_name: d.customer_name || "",
created_at: d.created_at ? d.created_at.substring(0, 10) : "",
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
};
setForm(formData);
const mappedItems =
Array.isArray(d.items) && d.items.length
? d.items.map((it) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()];
setItems(mappedItems);
const mappedSections =
Array.isArray(d.sections) && d.sections.length
? d.sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [];
setSections(mappedSections);
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null);
setLockedBy(d.locked_by ?? null);
// Try to acquire lock if not locked by someone else, not invalidated, and not completed
if (
!d.locked_by &&
d.status !== "invalidated" &&
d.order?.status !== "dokoncena" &&
hasPermission("offers.edit")
) {
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
() => {},
);
}
formInitializedRef.current = true;
}, [offerQuery.data, companySettings, hasPermission, id, emptyItem]);
// Redirect on offer fetch error (edit mode).
// Suppress when deletingRef is set: the in-flight refetch of a row we
// just deleted will 404, and we don't want a "load failed" toast on
// top of the success toast.
useEffect(() => {
if (isEdit && offerQuery.isError && !deletingRef.current) {
alert.error("Nepodařilo se načíst nabídku");
navigate("/offers");
}
}, [isEdit, offerQuery.isError, alert, navigate]);
// Sync next-number data to form (create mode)
useEffect(() => {
if (isEdit || !nextNumberData) return;
const num = nextNumberData.next_number || nextNumberData.number || "";
if (num) {
setForm((prev) => ({ ...prev, quotation_number: num }));
}
}, [isEdit, nextNumberData]);
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
if (!isEdit || !id || isLockedByOther || isInvalidated || isCompleted)
return;
heartbeatRef.current = setInterval(() => {
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" })
.then((res) => {
// 401 here means the access token expired AND the refresh failed
// (session truly gone — apiFetch retries with a refreshed token on a
// recoverable 401 and would return 200). Stop the interval so we don't
// spam a 401 every 10s while the user sits idle on a dead session;
// apiFetch has already flagged session-expired for the next action.
if (res.status === 401 && heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
})
.catch(() => {});
}, 10 * 1000); // every 10 seconds
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
if (unlockAbortRef.current) unlockAbortRef.current.abort();
// Release lock on unmount
const controller = new AbortController();
unlockAbortRef.current = controller;
apiFetch(`${API_BASE}/offers/${id}/unlock`, {
method: "POST",
signal: controller.signal,
}).catch(() => {});
};
}, [isEdit, id, isLockedByOther, isInvalidated, isCompleted]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false);
if (showCustomerDropdown) {
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [showCustomerDropdown]);
// Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500);
useEffect(() => {
if (isEdit) return;
try {
const data = JSON.parse(debouncedDraft);
const draft = {
form: {
project_code: data.form.project_code ?? "",
customer_id: data.form.customer_id ?? null,
customer_name: data.form.customer_name ?? "",
created_at: data.form.created_at ?? "",
valid_until: data.form.valid_until ?? "",
currency: data.form.currency ?? "CZK",
language: data.form.language ?? "EN",
vat_rate: data.form.vat_rate ?? 21,
apply_vat: data.form.apply_vat ?? false,
},
items: data.items,
sections: data.sections,
savedAt: new Date().toISOString(),
};
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch (e) {
console.error("Failed to save offer draft:", e);
}
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
const updateForm = (field: keyof OfferForm, value: unknown) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
const selectCustomer = (c: Customer) => {
setForm((prev) => ({ ...prev, customer_id: c.id, customer_name: c.name }));
setErrors((prev) => ({ ...prev, customer_id: undefined }));
setCustomerSearch("");
setShowCustomerDropdown(false);
};
const clearCustomer = () => {
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
};
const updateItem = (
index: number,
field: keyof OfferItem,
value: unknown,
) => {
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
);
};
const addItem = () => setItems((prev) => [...prev, emptyItem()]);
const removeItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
const subtotal = items.reduce((sum, item) => {
if (item.is_included_in_total) {
return (
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
);
}
return sum;
}, 0);
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
const total = subtotal + vatAmount;
const filteredCustomers = customerSearch
? customers.filter((c) =>
c.name.toLowerCase().includes(customerSearch.toLowerCase()),
)
: customers;
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný";
if (!form.created_at) newErrors.created_at = "Datum je povinné";
if (!form.valid_until) newErrors.valid_until = "Platnost je povinná";
if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
const payload: any = {
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
};
if (!isEdit) delete payload.quotation_number;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
const offerId = isEdit ? id : result.data?.id;
if (offerId) {
await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch(
() => {},
);
}
alert.success(
result.message ||
(isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"),
);
if (!isEdit) {
try {
localStorage.removeItem(DRAFT_KEY);
} catch (e) {
console.error("Failed to remove offer draft:", e);
}
}
if (!isEdit && result.data?.id) {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
});
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
const handleCreateOrder = async () => {
if (!customerOrderNumber.trim()) {
alert.error("Číslo objednávky zákazníka je povinné");
return;
}
setCreatingOrder(true);
try {
let fetchOptions: RequestInit;
if (orderAttachment) {
// With attachment: send as multipart/form-data
const formData = new FormData();
formData.append("quotationId", String(id));
formData.append("customerOrderNumber", customerOrderNumber.trim());
formData.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: formData };
} else {
// Without attachment: send as JSON
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quotationId: id,
customerOrderNumber: customerOrderNumber.trim(),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCreatingOrder(false);
}
};
const handleInvalidateOffer = async () => {
setInvalidatingOffer(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}/invalidate`, {
method: "POST",
});
const result = await response.json();
if (result.success) {
setInvalidateConfirm(false);
setOfferStatus("invalidated");
alert.success(result.message || "Nabídka byla zneplatněna");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setInvalidatingOffer(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
// Mark the row as gone before the list invalidation refetches the
// detail key (which would 404 and trigger the error-toast effect).
deletingRef.current = true;
queryClient.removeQueries({ queryKey: ["offers", id] });
alert.success(result.message || "Nabídka byla smazána");
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate("/offers");
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
}
};
const handlePdf = async () => {
if (!isEdit || pdfLoading) return;
const newWindow = window.open("", "_blank");
setPdfLoading(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}/file`);
if (response.status === 401) {
newWindow?.close();
return;
}
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — uložte nabídku pro vygenerování");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba při generování PDF");
} finally {
setPdfLoading(false);
}
};
const getRequiredPerm = () => {
if (!isEdit) return "offers.create";
return isInvalidated || isCompleted ? "offers.view" : "offers.edit";
};
const requiredPerm = getRequiredPerm();
if (!hasPermission(requiredPerm)) return <Forbidden />;
if (loading) {
return <LoadingState />;
}
return (
<PageEnter>
{/* Header */}
<Box>
<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="/offers"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
</Typography>
{isInvalidated && (
<StatusChip label="Zneplatněna" color="error" />
)}
{isCompleted && <StatusChip label="Dokončeno" color="success" />}
</Box>
</Box>
<Box sx={headerActionsSx}>
{isEdit && hasPermission("offers.export") && (
<Button
onClick={handlePdf}
variant="outlined"
color="inherit"
disabled={pdfLoading}
startIcon={
pdfLoading ? (
<CircularProgress size={16} color="inherit" />
) : (
FileIcon
)
}
>
Zobrazit nabídku
</Button>
)}
{isEdit &&
!readOnly &&
hasPermission("orders.create") &&
!orderInfo && (
<Button
onClick={() => {
setCustomerOrderNumber("");
setOrderAttachment(null);
setShowOrderModal(true);
}}
variant="outlined"
color="inherit"
>
Vytvořit objednávku
</Button>
)}
{isEdit && orderInfo && (
<Button
component={RouterLink}
to={`/orders/${orderInfo.id}`}
variant="outlined"
color="inherit"
>
Objednávka {orderInfo.order_number}
</Button>
)}
{canInvalidate && hasPermission("offers.edit") && (
<Button
onClick={() => setInvalidateConfirm(true)}
variant="outlined"
color="inherit"
>
Zneplatnit
</Button>
)}
{!readOnly && (
<Button onClick={handleSave} disabled={saving}>
{saving ? "Ukládání..." : "Uložit"}
</Button>
)}
{isEdit && hasPermission("offers.delete") && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</Box>
</Box>
</Box>
{/* Lock banner */}
{isLockedByOther && (
<Box
sx={{
bgcolor:
"color-mix(in srgb, var(--mui-palette-warning-main) 15%, transparent)",
border: 1,
borderColor: "warning.main",
borderRadius: 1,
px: 2,
py: 1.5,
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
fontSize: "0.875rem",
color: "warning.main",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>
Nabídku právě upravuje <strong>{lockedBy!.full_name}</strong>.
Můžete ji pouze prohlížet.
</span>
</Box>
)}
{/* Quotation Form */}
<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="Číslo nabídky">
<TextField
value={form.quotation_number}
InputProps={{ readOnly: true }}
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
/>
</Field>
<Field label="Kód projektu">
<TextField
value={form.project_code}
onChange={(e) => updateForm("project_code", e.target.value)}
placeholder="Volitelný kód projektu"
InputProps={{ readOnly }}
/>
</Field>
<Field label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1.5,
py: 1,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Box component="span" sx={{ flex: 1 }}>
{form.customer_name}
</Box>
{!readOnly && (
<IconButton
size="small"
onClick={clearCustomer}
title="Odebrat zákazníka"
aria-label="Odebrat zákazníka"
>
<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>
</IconButton>
)}
</Box>
) : (
<Box
sx={{ position: "relative" }}
onClick={(e) => e.stopPropagation()}
>
<TextField
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
placeholder="Hledat zákazníka..."
InputProps={{ readOnly }}
/>
{showCustomerDropdown && !isInvalidated && (
<Box
sx={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
zIndex: 20,
mt: 0.5,
maxHeight: 280,
overflowY: "auto",
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 1,
boxShadow: 3,
}}
>
{filteredCustomers.length === 0 ? (
<Box sx={{ px: 1.5, py: 1, color: "text.secondary" }}>
Žádní zákazníci
</Box>
) : (
filteredCustomers.slice(0, 20).map((c) => (
<Box
key={c.id}
onMouseDown={() => selectCustomer(c)}
sx={{
px: 1.5,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<Box>{c.name}</Box>
{c.city && (
<Box
sx={{
fontSize: "0.8rem",
color: "text.secondary",
}}
>
{c.city}
</Box>
)}
</Box>
))
)}
</Box>
)}
</Box>
)}
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Datum vytvoření" error={errors.created_at} required>
{readOnly ? (
<TextField
value={form.created_at}
InputProps={{ readOnly: true }}
/>
) : (
<DateField
value={form.created_at}
onChange={(val) => {
updateForm("created_at", val);
setErrors((prev) => ({ ...prev, created_at: undefined }));
}}
/>
)}
</Field>
<Field label="Platnost do" error={errors.valid_until} required>
{readOnly ? (
<TextField
value={form.valid_until}
InputProps={{ readOnly: true }}
/>
) : (
<DateField
value={form.valid_until}
onChange={(val) => {
updateForm("valid_until", val);
setErrors((prev) => ({
...prev,
valid_until: undefined,
}));
}}
/>
)}
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Měna">
<Select
value={form.currency}
onChange={(val) => updateForm("currency", val)}
disabled={readOnly}
options={(
companySettings?.available_currencies || [
"CZK",
"EUR",
"USD",
"GBP",
]
).map((c) => ({ value: c, label: c }))}
/>
</Field>
<Field label="Jazyk nabídky">
<Select
value={form.language}
onChange={(val) => updateForm("language", val)}
disabled={readOnly}
options={[
{ value: "EN", label: "English" },
{ value: "CZ", label: "Čeština" },
]}
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Sazba DPH (%)">
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Select
value={String(form.vat_rate)}
onChange={(val) => updateForm("vat_rate", parseFloat(val) || 0)}
disabled={readOnly}
sx={{ flex: 1 }}
options={(
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
).map((r) => ({ value: String(r), label: `${r}%` }))}
/>
<Box
component="label"
sx={{
display: "flex",
alignItems: "center",
whiteSpace: "nowrap",
cursor: readOnly ? "default" : "pointer",
}}
>
<Checkbox
checked={form.apply_vat}
onChange={(e) => updateForm("apply_vat", e.target.checked)}
disabled={readOnly}
/>
<Box component="span">Účtovat DPH</Box>
</Box>
</Box>
</Field>
</Box>
</Card>
{/* Items Section with drag-and-drop */}
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Položky
</Typography>
{!readOnly && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{itemTemplates && itemTemplates.length > 0 && (
<Select
value=""
onChange={(val) => {
const templateId = Number(val);
if (!templateId) return;
const template = itemTemplates.find(
(t: ItemTemplate) => t.id === templateId,
);
if (template) {
setItems((prev) => [
...prev,
{
_key: `item-${++itemKeyCounter.current}`,
description: template.name || "",
item_description: template.description || "",
quantity: 1,
unit: "ks",
unit_price: template.default_price || 0,
is_included_in_total: true,
},
]);
alert.success(
`Načtena šablona položky "${template.name}"`,
);
}
}}
sx={{ minWidth: 160 }}
options={[
{ value: "", label: "Ze šablony..." },
...itemTemplates.map((t: ItemTemplate) => ({
value: String(t.id),
label: t.name,
})),
]}
/>
)}
<Button
onClick={addItem}
variant="outlined"
color="inherit"
size="small"
>
+ Přidat položku
</Button>
</Box>
)}
</Box>
{errors.items && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1 }}
>
{errors.items}
</Typography>
)}
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(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);
});
}}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
{isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
item={item}
index={index}
currency={form.currency}
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
))}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
{!readOnly && <TableCell sx={{ width: "2rem" }} />}
<TableCell sx={{ width: "2.5rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "5rem" }}>Množství</TableCell>
<TableCell sx={{ width: "5rem" }}>Jednotka</TableCell>
<TableCell sx={{ width: "7rem" }}>Cena/ks</TableCell>
<TableCell sx={{ width: "4rem" }} align="center">
V ceně
</TableCell>
<TableCell sx={{ width: "7rem" }} align="right">
Celkem
</TableCell>
{!readOnly && <TableCell sx={{ width: "3rem" }} />}
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
item={item}
index={index}
currency={form.currency}
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
))}
</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(subtotal, form.currency)}
</Typography>
</Box>
{form.apply_vat && (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
DPH ({form.vat_rate}%):
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(vatAmount, 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(total, form.currency)}
</Typography>
</Box>
</Box>
</Card>
{/* Scope/Range Section */}
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Rozsah projektu
</Typography>
{!readOnly && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{scopeTemplates.length > 0 && (
<Select
value=""
onChange={(val) => {
const templateId = Number(val);
if (!templateId) return;
const template = scopeTemplates.find(
(t) => t.id === templateId,
);
if (template?.scope_template_sections?.length) {
const newSections = template.scope_template_sections.map(
(s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}),
);
setSections((prev) => [...prev, ...newSections]);
alert.success(`Načtena šablona "${template.name}"`);
}
}}
sx={{ minWidth: 160 }}
options={[
{ value: "", label: "Ze šablony..." },
...scopeTemplates.map((t) => ({
value: String(t.id),
label: t.name,
})),
]}
/>
)}
<Button
onClick={() =>
setSections((prev) => [...prev, emptyScopeSection()])
}
variant="outlined"
color="inherit"
size="small"
>
+ Přidat sekci
</Button>
</Box>
)}
</Box>
{sections.length === 0 ? (
<EmptyState title='Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.' />
) : (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
mt: 1,
}}
>
{sections.map((section, idx) => (
<Box
key={idx}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 2,
bgcolor: "action.hover",
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1.5,
}}
>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Sekce {idx + 1}
{(form.language === "CZ"
? section.title_cz
: section.title) && (
<Box component="span" sx={{ fontWeight: 400, ml: 1 }}>
{" "}
{form.language === "CZ"
? section.title_cz || section.title
: section.title}
</Box>
)}
</Typography>
{!readOnly && (
<Box sx={{ display: "flex", gap: 0.25 }}>
{idx > 0 && (
<IconButton
size="small"
onClick={() =>
setSections((prev) => {
const arr = [...prev];
[arr[idx - 1], arr[idx]] = [
arr[idx],
arr[idx - 1],
];
return arr;
})
}
title="Posunout nahoru"
aria-label="Posunout nahoru"
>
{UpIcon}
</IconButton>
)}
{idx < sections.length - 1 && (
<IconButton
size="small"
onClick={() =>
setSections((prev) => {
const arr = [...prev];
[arr[idx], arr[idx + 1]] = [
arr[idx + 1],
arr[idx],
];
return arr;
})
}
title="Posunout dolů"
aria-label="Posunout dolů"
>
{DownIcon}
</IconButton>
)}
<IconButton
size="small"
color="error"
onClick={() =>
setSections((prev) =>
prev.filter((_, i) => i !== idx),
)
}
title="Odebrat sekci"
aria-label="Odebrat sekci"
>
{RemoveIcon}
</IconButton>
</Box>
)}
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
mb: 0.5,
}}
>
<StatusChip label="EN" color="info" />
<Typography
component="label"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Název sekce
</Typography>
</Box>
<TextField
value={section.title}
onChange={(e) =>
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, title: e.target.value } : s,
),
)
}
placeholder="Název sekce (anglicky)"
InputProps={{ readOnly }}
/>
</Box>
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
mb: 0.5,
}}
>
<StatusChip label="CZ" color="success" />
<Typography
component="label"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Název sekce
</Typography>
</Box>
<TextField
value={section.title_cz}
onChange={(e) =>
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, title_cz: e.target.value } : s,
),
)
}
placeholder="Název sekce (česky)"
InputProps={{ readOnly }}
/>
</Box>
</Box>
<Field label="Obsah">
<RichEditor
value={section.content}
onChange={(val) =>
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, content: val } : s,
),
)
}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={readOnly}
/>
</Field>
</Box>
))}
</Box>
)}
</Card>
{/* Order modal */}
<Modal
isOpen={showOrderModal}
onClose={() => !creatingOrder && setShowOrderModal(false)}
onSubmit={handleCreateOrder}
title="Vytvořit objednávku"
submitText={creatingOrder ? "Vytváření..." : "Vytvořit"}
loading={creatingOrder}
maxWidth="sm"
>
<Field label="Číslo objednávky zákazníka" required>
<TextField
value={customerOrderNumber}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" && !creatingOrder && handleCreateOrder()
}
placeholder="Např. PO-2026-001"
autoFocus
/>
</Field>
<Field label="Příloha (PDF)" hint="Max 10 MB">
{orderAttachment ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box component="span">
{orderAttachment.name}{" "}
<Box component="span" sx={{ color: "text.secondary" }}>
({(orderAttachment.size / 1024).toFixed(0)} KB)
</Box>
</Box>
<IconButton
size="small"
onClick={() => setOrderAttachment(null)}
title="Odebrat"
aria-label="Odebrat"
sx={{ ml: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</IconButton>
</Box>
) : (
<Button
component="label"
variant="outlined"
color="inherit"
size="small"
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</Button>
)}
</Field>
</Modal>
<ConfirmDialog
isOpen={invalidateConfirm}
onClose={() => setInvalidateConfirm(false)}
onConfirm={handleInvalidateOffer}
title="Zneplatnit nabídku"
message={`Opravdu chcete zneplatnit nabídku "${form.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
confirmText="Zneplatnit"
cancelText="Zrušit"
confirmVariant="danger"
loading={invalidatingOffer}
/>
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat nabídku"
message={`Opravdu chcete smazat nabídku "${form.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
</PageEnter>
);
}