Files
app/src/admin/pages/OfferDetail.tsx
BOHA 2e4fe2a8fe fix(offers): stop heartbeat 401 spam on dead session + de-dup item keys
Three console issues observed while idle on an offer:

1. Heartbeat flooded the console with 401s. The lock-keepalive interval
   fired apiFetch(...).catch(() => {}) every 10s; apiFetch RESOLVES with a
   401 (it doesn't throw), so .catch never saw it and the interval never
   stopped. Once the refresh token had also expired, every tick 401'd
   forever. Now we inspect the response and clearInterval on a 401 (which
   only occurs after a failed refresh = session truly gone); the healthy
   path still returns 200 and keeps ticking (verified in Chrome).

2. Duplicate React key "item-1" in the line-items table. The form/items/
   sections useState initializers restored the create-mode draft
   UNCONDITIONALLY — even in edit mode — and reused the draft's stale
   item _key values while itemKeyCounter restarts at 0 each load, so an
   added row collided. Now the draft is only restored when !isEdit, and
   restored items are re-keyed with fresh unique _keys.

3. framer-motion "motion() is deprecated" warning — PageEnter used the
   deprecated motion(Box) factory; switched to motion.create(Box).

tsc -b --noEmit, npm run build, vitest 152/152 clean. Console verified
clean in Chrome (only benign dev notices remain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:28:49 +02:00

2062 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,
} 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]);
// 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={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
{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)} 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>
);
}