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 = (
);
const FileIcon = (
);
const DragIcon = (
);
const RemoveIcon = (
);
const UpIcon = (
);
const DownIcon = (
);
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 (
{!readOnly && (
{DragIcon}
)}
Položka {index + 1}
{!readOnly && (
{RemoveIcon}
)}
onUpdate("description", e.target.value)}
placeholder="Název položky"
InputProps={{ readOnly }}
/>
onUpdate("item_description", e.target.value)}
placeholder="Volitelný"
InputProps={{ readOnly }}
/>
onUpdate("quantity", parseInt(e.target.value, 10))}
slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }}
/>
onUpdate("unit", e.target.value)}
InputProps={{ readOnly }}
/>
onUpdate("unit_price", parseFloat(e.target.value))}
slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }}
/>
onUpdate("is_included_in_total", e.target.checked)
}
disabled={readOnly}
/>
V ceně
Celkem
{formatCurrency(lineTotal, currency)}
);
}
return (
{!readOnly && (
{DragIcon}
)}
{index + 1}
onUpdate("description", e.target.value)}
placeholder="Název položky"
InputProps={{ readOnly }}
sx={{ "& input": { fontWeight: 500 } }}
/>
onUpdate("item_description", e.target.value)}
placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }}
sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }}
/>
onUpdate("quantity", parseInt(e.target.value, 10))}
slotProps={{ htmlInput: { step: "1" } }}
InputProps={{ readOnly }}
/>
onUpdate("unit", e.target.value)}
InputProps={{ readOnly }}
/>
onUpdate("unit_price", parseFloat(e.target.value))}
slotProps={{ htmlInput: { step: "0.01" } }}
InputProps={{ readOnly }}
/>
onUpdate("is_included_in_total", e.target.checked)}
disabled={readOnly}
/>
{formatCurrency(lineTotal, currency)}
{!readOnly && (
{RemoveIcon}
)}
);
}
function loadOfferDraft(): {
form?: Record;
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>({});
const [form, setForm] = useState(() => {
// 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(() => {
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(() => {
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(null);
const [offerStatus, setOfferStatus] = useState("");
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(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef[]>([]);
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 | null>(null);
const unlockAbortRef = useRef(null);
const initialSnapshotRef = useRef(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 = {};
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 ;
if (loading) {
return ;
}
return (
{/* Header */}
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
{isInvalidated && (
)}
{isCompleted && }
{isEdit && hasPermission("offers.export") && (
) : (
FileIcon
)
}
>
Zobrazit nabídku
)}
{isEdit &&
!readOnly &&
hasPermission("orders.create") &&
!orderInfo && (
)}
{isEdit && orderInfo && (
)}
{canInvalidate && hasPermission("offers.edit") && (
)}
{!readOnly && (
)}
{isEdit && hasPermission("offers.delete") && (
)}
{/* Lock banner */}
{isLockedByOther && (
Nabídku právě upravuje {lockedBy!.full_name}.
Můžete ji pouze prohlížet.
)}
{/* Quotation Form */}
Základní údaje
updateForm("project_code", e.target.value)}
placeholder="Volitelný kód projektu"
InputProps={{ readOnly }}
/>
{form.customer_id ? (
{form.customer_name}
{!readOnly && (
)}
) : (
e.stopPropagation()}
>
{
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
placeholder="Hledat zákazníka..."
InputProps={{ readOnly }}
/>
{showCustomerDropdown && !isInvalidated && (
{filteredCustomers.length === 0 ? (
Žádní zákazníci
) : (
filteredCustomers.slice(0, 20).map((c) => (
selectCustomer(c)}
sx={{
px: 1.5,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
{c.name}
{c.city && (
{c.city}
)}
))
)}
)}
)}
{readOnly ? (
) : (
{
updateForm("created_at", val);
setErrors((prev) => ({ ...prev, created_at: undefined }));
}}
/>
)}
{readOnly ? (
) : (
{
updateForm("valid_until", val);
setErrors((prev) => ({
...prev,
valid_until: undefined,
}));
}}
/>
)}
{/* Items Section with drag-and-drop */}
Položky
{!readOnly && (
{itemTemplates && itemTemplates.length > 0 && (
)}
{errors.items && (
{errors.items}
)}
{
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);
});
}}
>
i._key)}
strategy={verticalListSortingStrategy}
>
{isMobile ? (
{items.map((item, index) => (
1}
onUpdate={(field, value) =>
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
))}
) : (
{!readOnly && }
#
Popis
Množství
Jednotka
Cena/ks
V ceně
Celkem
{!readOnly && }
{items.map((item, index) => (
1}
onUpdate={(field, value) =>
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
))}
)}
{/* Totals */}
Mezisoučet:
{formatCurrency(subtotal, form.currency)}
{form.apply_vat && (
DPH ({form.vat_rate}%):
{formatCurrency(vatAmount, form.currency)}
)}
Celkem:
{formatCurrency(total, form.currency)}
{/* Scope/Range Section */}
Rozsah projektu
{!readOnly && (
{scopeTemplates.length > 0 && (
)}
{sections.length === 0 ? (
) : (
{sections.map((section, idx) => (
Sekce {idx + 1}
{(form.language === "CZ"
? section.title_cz
: section.title) && (
—{" "}
{form.language === "CZ"
? section.title_cz || section.title
: section.title}
)}
{!readOnly && (
{idx > 0 && (
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}
)}
{idx < sections.length - 1 && (
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}
)}
setSections((prev) =>
prev.filter((_, i) => i !== idx),
)
}
title="Odebrat sekci"
aria-label="Odebrat sekci"
>
{RemoveIcon}
)}
Název sekce
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, title: e.target.value } : s,
),
)
}
placeholder="Název sekce (anglicky)"
InputProps={{ readOnly }}
/>
Název sekce
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, title_cz: e.target.value } : s,
),
)
}
placeholder="Název sekce (česky)"
InputProps={{ readOnly }}
/>
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, content: val } : s,
),
)
}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={readOnly}
/>
))}
)}
{/* Order modal */}
!creatingOrder && setShowOrderModal(false)}
onSubmit={handleCreateOrder}
title="Vytvořit objednávku"
submitText={creatingOrder ? "Vytváření..." : "Vytvořit"}
loading={creatingOrder}
maxWidth="sm"
>
) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" && !creatingOrder && handleCreateOrder()
}
placeholder="Např. PO-2026-001"
autoFocus
/>
{orderAttachment ? (
{orderAttachment.name}{" "}
({(orderAttachment.size / 1024).toFixed(0)} KB)
setOrderAttachment(null)}
title="Odebrat"
aria-label="Odebrat"
sx={{ ml: "auto" }}
>
) : (
)}
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}
/>
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}
/>
);
}