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>
2060 lines
66 KiB
TypeScript
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>
|
|
);
|
|
}
|