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