feat(documents)!: unify offers and issued orders end to end
One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
653
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
653
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
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 useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
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 { Button, Card, TextField } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
/**
|
||||
* One sortable line item of a document (offer / issued order). The numeric
|
||||
* fields are held as the raw typed string while editing so the input can be
|
||||
* cleared (empty renders fine in a type="number" input); coerce via
|
||||
* `Number(x) || 0` only where used (live totals + save payload).
|
||||
*/
|
||||
export interface DocumentItem {
|
||||
_key: string;
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Offers only — undefined (issued orders) counts as included. */
|
||||
is_included_in_total?: boolean;
|
||||
}
|
||||
|
||||
let documentItemKeyCounter = 0;
|
||||
|
||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||
export const nextDocumentItemKey = (): string =>
|
||||
`item-${++documentItemKeyCounter}`;
|
||||
|
||||
export const emptyDocumentItem = (): DocumentItem => ({
|
||||
_key: nextDocumentItemKey(),
|
||||
description: "",
|
||||
item_description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
});
|
||||
|
||||
/** NET total over the included items (documents here carry no VAT). */
|
||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||
items.reduce(
|
||||
(sum, it) =>
|
||||
it.is_included_in_total === false
|
||||
? sum
|
||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
function SortableItemRow({
|
||||
item,
|
||||
index,
|
||||
currency,
|
||||
readOnly,
|
||||
canDelete,
|
||||
showIncludedInTotal,
|
||||
itemDescriptionMaxLength,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DocumentItem;
|
||||
index: number;
|
||||
currency: string;
|
||||
readOnly: boolean;
|
||||
canDelete: boolean;
|
||||
showIncludedInTotal: boolean;
|
||||
itemDescriptionMaxLength: number;
|
||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => 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="Popis položky…"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Podrobný popis"
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<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", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||
placeholder="ks"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jedn. cena"
|
||||
type="number"
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: showIncludedInTotal ? "space-between" : "flex-end",
|
||||
alignItems: "baseline",
|
||||
gap: 1,
|
||||
pt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{showIncludedInTotal && (
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
cursor: readOnly ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.is_included_in_total !== false}
|
||||
onChange={(e) =>
|
||||
onUpdate("is_included_in_total", e.target.checked)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Typography variant="body2">V ceně</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Celkem
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(lineTotal, currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} sx={style}>
|
||||
{/* Stable column layout: every cell renders in read-only mode too —
|
||||
only the controls inside are hidden. */}
|
||||
<TableCell sx={{ width: "2rem", px: 0.5 }}>
|
||||
{!readOnly && (
|
||||
<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", fontWeight: 500 }}
|
||||
>
|
||||
{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="Popis položky…"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
sx={{ "& input": { fontWeight: 500 } }}
|
||||
/>
|
||||
<TextField
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
opacity: 0.8,
|
||||
resize: "vertical",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||
placeholder="ks"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
{showIncludedInTotal && (
|
||||
<TableCell align="center">
|
||||
<Checkbox
|
||||
checked={item.is_included_in_total !== false}
|
||||
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
}}
|
||||
>
|
||||
{formatCurrency(lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "2.5rem" }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={onRemove}
|
||||
title="Odebrat"
|
||||
aria-label="Odebrat"
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentItemsEditorProps {
|
||||
items: DocumentItem[];
|
||||
onChange: (items: DocumentItem[]) => void;
|
||||
currency: string;
|
||||
readOnly: boolean;
|
||||
/** Validation error shown under the section title. */
|
||||
error?: string;
|
||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||
showIncludedInTotal?: boolean;
|
||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||
itemDescriptionMaxLength?: number;
|
||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||
templatesSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortable line-items table shared by the document detail pages (offers,
|
||||
* issued orders): drag-and-drop reorder, mobile card layout, live NET totals
|
||||
* ("Celkem bez DPH" — these documents are not tax documents).
|
||||
*/
|
||||
export default function DocumentItemsEditor({
|
||||
items,
|
||||
onChange,
|
||||
currency,
|
||||
readOnly,
|
||||
error,
|
||||
showIncludedInTotal = false,
|
||||
itemDescriptionMaxLength = 5000,
|
||||
templatesSlot,
|
||||
}: DocumentItemsEditorProps) {
|
||||
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 updateItem = (
|
||||
index: number,
|
||||
field: keyof DocumentItem,
|
||||
value: string | boolean,
|
||||
) =>
|
||||
onChange(
|
||||
items.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
|
||||
);
|
||||
|
||||
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (items.length <= 1) return;
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = items.findIndex((i) => i._key === String(active.id));
|
||||
const newIndex = items.findIndex((i) => i._key === String(over.id));
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
onChange(arrayMove(items, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const total = documentItemsTotal(items);
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="caption" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
{templatesSlot}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
+ Přidat položku
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<DndContext
|
||||
sensors={dndSensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<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={currency}
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer sx={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 720,
|
||||
"& td, & th": { borderColor: "divider" },
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: "2rem" }} />
|
||||
<TableCell sx={{ width: "2rem" }} align="center">
|
||||
#
|
||||
</TableCell>
|
||||
<TableCell>Popis</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Množství
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||
Jednotka
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{showIncludedInTotal && (
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
V ceně
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell sx={{ width: "8rem" }} align="right">
|
||||
Celkem
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "2.5rem" }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
currency={currency}
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) =>
|
||||
updateItem(index, field, value)
|
||||
}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Totals (NET only — offers/issued orders are not tax documents) */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
ml: "auto",
|
||||
maxWidth: 360,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
pt: 0.5,
|
||||
mt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Celkem bez DPH:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(total, currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
src/admin/components/document/LockBanner.tsx
Normal file
55
src/admin/components/document/LockBanner.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import type { DocumentLockInfo } from "../../hooks/useDocumentLock";
|
||||
|
||||
/**
|
||||
* Warning banner shown when another user holds the document edit lock
|
||||
* (extracted from OfferDetail; shared by the document detail pages).
|
||||
* Renders nothing when the lock is free / held by the current user.
|
||||
*
|
||||
* `entityWord` is the Czech accusative for the document ("Nabídku" /
|
||||
* "Objednávku") — both are feminine, so the rest of the sentence is shared.
|
||||
*/
|
||||
export default function LockBanner({
|
||||
lockedBy,
|
||||
entityWord,
|
||||
}: {
|
||||
lockedBy: DocumentLockInfo | null;
|
||||
entityWord: string;
|
||||
}) {
|
||||
if (!lockedBy) return null;
|
||||
return (
|
||||
<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>
|
||||
{entityWord} právě upravuje <strong>{lockedBy.full_name}</strong>.
|
||||
Můžete ji pouze prohlížet.
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
315
src/admin/components/document/SectionsEditor.tsx
Normal file
315
src/admin/components/document/SectionsEditor.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import RichEditor from "../RichEditor";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
TextField,
|
||||
Field,
|
||||
StatusChip,
|
||||
EmptyState,
|
||||
} from "../../ui";
|
||||
|
||||
/** One bilingual rich-text PDF section (offers' scope ≡ issued orders' sections). */
|
||||
export interface DocumentSection {
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const emptyDocumentSection = (): DocumentSection => ({
|
||||
title: "",
|
||||
title_cz: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
interface SectionsEditorProps {
|
||||
sections: DocumentSection[];
|
||||
onChange: (sections: DocumentSection[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Card heading. */
|
||||
title?: string;
|
||||
/**
|
||||
* Document language — "CZ"/"cs" prefers the Czech section title in the
|
||||
* "Sekce N — …" header line, anything else the English one.
|
||||
*/
|
||||
language?: string;
|
||||
/** Optional page-specific control (e.g. scope-template Select) next to the add button. */
|
||||
templatesSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilingual rich-text sections editor shared by the document detail pages
|
||||
* (lifted from OfferDetail's "Rozsah projektu" card): bordered section boxes
|
||||
* with EN/CZ title fields (StatusChip badges), Quill content, up/down
|
||||
* reorder and add/remove. Offers pass their template Select via
|
||||
* `templatesSlot`; issued orders don't (no templates on POs).
|
||||
*/
|
||||
export default function SectionsEditor({
|
||||
sections,
|
||||
onChange,
|
||||
readOnly,
|
||||
title = "Rozsah projektu",
|
||||
language,
|
||||
templatesSlot,
|
||||
}: SectionsEditorProps) {
|
||||
const preferCz =
|
||||
(language ?? "").toLowerCase().startsWith("cs") ||
|
||||
(language ?? "").toUpperCase() === "CZ";
|
||||
|
||||
const updateSection = (
|
||||
index: number,
|
||||
field: keyof DocumentSection,
|
||||
value: string,
|
||||
) =>
|
||||
onChange(
|
||||
sections.map((s, i) => (i === index ? { ...s, [field]: value } : s)),
|
||||
);
|
||||
|
||||
const moveSection = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= sections.length) return;
|
||||
const arr = [...sections];
|
||||
[arr[index], arr[target]] = [arr[target], arr[index]];
|
||||
onChange(arr);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
{templatesSlot}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onChange([...sections, emptyDocumentSection()])}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
+ Přidat sekci
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={
|
||||
templatesSlot
|
||||
? 'Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.'
|
||||
: 'Žádné sekce rozsahu. Klikněte na "Přidat sekci".'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
{(preferCz ? section.title_cz : section.title) && (
|
||||
<Box component="span" sx={{ fontWeight: 400, ml: 1 }}>
|
||||
—{" "}
|
||||
{preferCz
|
||||
? section.title_cz || section.title
|
||||
: section.title}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 0.25 }}>
|
||||
{idx > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(idx, -1)}
|
||||
title="Posunout nahoru"
|
||||
aria-label="Posunout nahoru"
|
||||
>
|
||||
{UpIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{idx < sections.length - 1 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(idx, 1)}
|
||||
title="Posunout dolů"
|
||||
aria-label="Posunout dolů"
|
||||
>
|
||||
{DownIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() =>
|
||||
onChange(sections.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) =>
|
||||
updateSection(idx, "title", e.target.value)
|
||||
}
|
||||
placeholder="Název sekce (anglicky)"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
</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) =>
|
||||
updateSection(idx, "title_cz", e.target.value)
|
||||
}
|
||||
placeholder="Název sekce (česky)"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Field label="Obsah">
|
||||
<RichEditor
|
||||
value={section.content}
|
||||
onChange={(val) => updateSection(idx, "content", val)}
|
||||
placeholder="Obsah sekce..."
|
||||
minHeight="120px"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
81
src/admin/hooks/useDocumentLock.ts
Normal file
81
src/admin/hooks/useDocumentLock.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
/** Lock holder as enriched by the detail endpoints (offers, issued orders). */
|
||||
export interface DocumentLockInfo {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side of the document edit lock shared by the offer / issued-order
|
||||
* detail pages (extracted from OfferDetail). The server exposes the same
|
||||
* trio under the entity base URL: POST `:base/lock`, `:base/heartbeat`,
|
||||
* `:base/unlock` (lock expires after 30 s without a heartbeat — 3 missed
|
||||
* 10 s beats, so background-tab interval throttling can't hand the lock away).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - `lockedBy` state — the page hydrates it from the detail response via
|
||||
* `setLockedBy(detail.locked_by ?? null)`.
|
||||
* - `acquire()` — fire-and-forget lock acquisition; the page calls it from
|
||||
* its one-shot hydration effect when the doc is editable, the user holds
|
||||
* the edit permission and nobody else holds the lock.
|
||||
* - 10 s heartbeat while `enabled` (and not locked by another user), with
|
||||
* hard-401 self-stop: a 401 here means the access token expired AND the
|
||||
* refresh failed (apiFetch retries recoverable 401s itself), so we stop
|
||||
* the interval instead of spamming a dead session every 10 s.
|
||||
* - unlock on unmount/disable, with an AbortController so a stale in-flight
|
||||
* unlock can be cancelled by the next one.
|
||||
*/
|
||||
export function useDocumentLock({
|
||||
baseUrl,
|
||||
enabled,
|
||||
}: {
|
||||
/** Entity endpoint base (e.g. `/api/admin/offers/42`); null in create mode. */
|
||||
baseUrl: string | null;
|
||||
/**
|
||||
* External conditions for holding the lock: edit mode + editable status +
|
||||
* edit permission. The hook adds `!isLockedByOther` itself.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [lockedBy, setLockedBy] = useState<DocumentLockInfo | null>(null);
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const unlockAbortRef = useRef<AbortController | null>(null);
|
||||
const isLockedByOther = lockedBy !== null;
|
||||
|
||||
const acquire = useCallback(() => {
|
||||
if (!baseUrl) return;
|
||||
apiFetch(`${baseUrl}/lock`, { method: "POST" }).catch(() => {});
|
||||
}, [baseUrl]);
|
||||
|
||||
// Heartbeat to keep the lock alive + release on unmount/disable.
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !enabled || isLockedByOther) return;
|
||||
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
apiFetch(`${baseUrl}/heartbeat`, { method: "POST" })
|
||||
.then((res) => {
|
||||
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();
|
||||
const controller = new AbortController();
|
||||
unlockAbortRef.current = controller;
|
||||
apiFetch(`${baseUrl}/unlock`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [baseUrl, enabled, isLockedByOther]);
|
||||
|
||||
return { lockedBy, isLockedByOther, setLockedBy, acquire };
|
||||
}
|
||||
86
src/admin/hooks/useDocumentPdf.ts
Normal file
86
src/admin/hooks/useDocumentPdf.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
/**
|
||||
* Shared "Zobrazit PDF" handlers for the document pages (offers, issued
|
||||
* orders) — both call their GET `…/:id/file` endpoint (archived NAS PDF with
|
||||
* live-render fallback). Hardened flow extracted from OfferDetail:
|
||||
* - pre-opens the tab synchronously (popup blockers only allow it inside the
|
||||
* click's call stack), then streams the blob URL into it;
|
||||
* - double-click guard via a ref (state updates are async, a ref is not);
|
||||
* - 401 → silently close the tab (apiFetch already flagged the dead session);
|
||||
* - blob URLs are revoked after 60 s; pending revoke timeouts are cleared on
|
||||
* unmount so they can't fire into a dead page.
|
||||
*/
|
||||
|
||||
/**
|
||||
* List variant — ONE hook instance serves every row. `openPdf(id, url)`
|
||||
* fetches that row's PDF; `pdfLoadingId` reports which row is in flight (use
|
||||
* it for the per-row spinner + disabled state).
|
||||
*/
|
||||
export function useDocumentListPdf() {
|
||||
const alert = useAlert();
|
||||
const [pdfLoadingId, setPdfLoadingId] = useState<number | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// The array object is stable (we only push to it, never reassign), so
|
||||
// capturing it here keeps the cleanup in sync with the ref's content.
|
||||
const timeouts = blobTimeoutsRef.current;
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPdf = useCallback(
|
||||
async (id: number, url: string) => {
|
||||
if (loadingRef.current) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
loadingRef.current = true;
|
||||
setPdfLoadingId(id);
|
||||
try {
|
||||
const response = await apiFetch(url);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("Nepodařilo se vygenerovat PDF");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrl;
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setPdfLoadingId(null);
|
||||
}
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
return { openPdf, pdfLoadingId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail variant — a single fixed URL, `openPdf()` takes no arguments.
|
||||
* Implemented on top of the list variant so the hardened flow lives once.
|
||||
*/
|
||||
export function useDocumentPdf(url: string | null) {
|
||||
const { openPdf: openListPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
|
||||
const openPdf = useCallback(async () => {
|
||||
if (!url) return;
|
||||
await openListPdf(0, url);
|
||||
}, [url, openListPdf]);
|
||||
|
||||
return { openPdf, pdfLoading: pdfLoadingId !== null };
|
||||
}
|
||||
41
src/admin/hooks/useUnsavedChangesGuard.ts
Normal file
41
src/admin/hooks/useUnsavedChangesGuard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Dirty-form guard shared by the document detail pages (extracted from
|
||||
* OfferDetail). Serializes `snapshot` (typically `{ form, items, sections }`)
|
||||
* every render, captures a baseline on the first `ready` render — or
|
||||
* explicitly via `markClean` — and registers a `beforeunload` warning while
|
||||
* the current snapshot differs from the baseline.
|
||||
*
|
||||
* `markClean(value?)`:
|
||||
* - with a value: baseline = that value (use from a hydration effect, where
|
||||
* the freshly-mapped objects are ahead of the not-yet-rendered state);
|
||||
* - without: baseline = the last RENDERED snapshot (use after a successful
|
||||
* save, where the handler's closure state equals the rendered state).
|
||||
*/
|
||||
export function useUnsavedChangesGuard(snapshot: unknown, ready: boolean) {
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const serializedRef = useRef(serialized);
|
||||
serializedRef.current = serialized;
|
||||
const baselineRef = useRef<string | null>(null);
|
||||
if (ready && baselineRef.current === null) baselineRef.current = serialized;
|
||||
const isDirty =
|
||||
baselineRef.current !== null && serialized !== baselineRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
const markClean = useCallback((value?: unknown) => {
|
||||
baselineRef.current =
|
||||
value !== undefined ? JSON.stringify(value) : serializedRef.current;
|
||||
}, []);
|
||||
|
||||
return { isDirty, markClean };
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
// Single shared definition (type-only import, erased at compile time) — the
|
||||
// canonical CurrencyAmount lives in offers.ts; re-exported here so existing
|
||||
// importers keep working.
|
||||
import type { CurrencyAmount } from "./offers";
|
||||
|
||||
export type { CurrencyAmount };
|
||||
|
||||
export interface IssuedOrder {
|
||||
id: number;
|
||||
@@ -34,6 +40,15 @@ export interface IssuedOrderItem {
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** Rich-text CZ/EN PDF section (mirrors offers' scope sections). */
|
||||
export interface IssuedOrderSection {
|
||||
id?: number;
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface IssuedOrderDetail extends IssuedOrder {
|
||||
exchange_rate: number | string | null;
|
||||
delivery_date: string | null;
|
||||
@@ -45,8 +60,15 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
||||
notes: string | null;
|
||||
internal_notes: string | null;
|
||||
items: IssuedOrderItem[];
|
||||
sections: IssuedOrderSection[];
|
||||
supplier: Record<string, unknown> | null;
|
||||
valid_transitions: string[];
|
||||
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
||||
locked_by: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
|
||||
@@ -65,6 +87,7 @@ export const issuedOrderListOptions = (filters: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
supplier_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
@@ -78,6 +101,8 @@ export const issuedOrderListOptions = (filters: {
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.supplier_id)
|
||||
params.set("supplier_id", String(filters.supplier_id));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
@@ -87,14 +112,10 @@ export const issuedOrderListOptions = (filters: {
|
||||
},
|
||||
});
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const issuedOrderStatsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
supplier_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
@@ -104,6 +125,8 @@ export const issuedOrderStatsOptions = (filters: {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.supplier_id)
|
||||
params.set("supplier_id", String(filters.supplier_id));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
@@ -120,4 +143,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
|
||||
queryFn: () =>
|
||||
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
|
||||
enabled: !!id,
|
||||
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
||||
// GETs and keeps the detail page on an infinite spinner.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export const issuedOrderNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-orders", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/issued-orders/next-number",
|
||||
).then((d) => d?.next_number || d?.number || ""),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,32 @@ export interface ApiError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full success envelope (`data` + server `message`) — the result shape of a
|
||||
* mutation created with `envelope: true`. Used by the document detail pages
|
||||
* (offers, issued orders) to pass the server's Czech success message through
|
||||
* to the toast instead of a hardcoded client string.
|
||||
*/
|
||||
export interface ApiEnvelope<T> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czech-safe error message for toasts: returns the server-provided message
|
||||
* from an `ApiError` (those carry `.status` and a Czech `error` string), and
|
||||
* falls back when the message is a client-generated English string — network
|
||||
* failures ("Failed to fetch" has no status), "Invalid JSON response",
|
||||
* "Unauthorized" (401) or the generic "Request failed (NNN)".
|
||||
*/
|
||||
export function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const status = (err as ApiError | null | undefined)?.status;
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
if (!message || status === undefined || status === 401) return fallback;
|
||||
if (/^Request failed \(\d+\)$/.test(message)) return fallback;
|
||||
return message;
|
||||
}
|
||||
|
||||
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface ApiResponseBody<T> {
|
||||
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
url: string | ((input: TIn) => string);
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
body?: TIn;
|
||||
envelope?: boolean;
|
||||
}): Promise<TOut> {
|
||||
const url =
|
||||
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
|
||||
@@ -59,6 +86,9 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (opts.envelope) {
|
||||
return { data: result.data, message: result.message } as TOut;
|
||||
}
|
||||
return result.data as TOut;
|
||||
}
|
||||
|
||||
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||
invalidate?: readonly string[];
|
||||
/**
|
||||
* When true, the mutation resolves with the full `ApiEnvelope` (`{ data,
|
||||
* message }`) instead of the bare `data` — declare `TOut` as
|
||||
* `ApiEnvelope<...>` accordingly.
|
||||
*/
|
||||
envelope?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +125,12 @@ export function useApiMutation<TIn, TOut>(
|
||||
opts: ApiMutationOptions<TIn, TOut> &
|
||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||
) {
|
||||
const { url, method, invalidate, onSuccess, ...rest } = opts;
|
||||
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TOut, ApiError, TIn, unknown>({
|
||||
mutationFn: (input: TIn) =>
|
||||
performMutation<TIn, TOut>({ url, method, body: input }),
|
||||
performMutation<TIn, TOut>({ url, method, body: input, envelope }),
|
||||
...rest,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (invalidate) {
|
||||
|
||||
@@ -52,7 +52,9 @@ export interface Customer {
|
||||
|
||||
export const offerCustomersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-customers"],
|
||||
// Under the broad ["offers"] domain so offer-domain invalidations
|
||||
// (customer CRUD, offer mutations) refresh the picker automatically.
|
||||
queryKey: ["offers", "customers"],
|
||||
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
|
||||
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
||||
});
|
||||
|
||||
/** Offer list row (the shape returned by GET /api/admin/offers). */
|
||||
export interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
export const offerListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
|
||||
if (filters.customer_id)
|
||||
params.set("customer_id", String(filters.customer_id));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
||||
return paginatedJsonQuery<Quotation>(
|
||||
`/api/admin/offers${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,6 +181,8 @@ export interface OfferDetailData {
|
||||
status: string;
|
||||
order: OfferOrderInfo | null;
|
||||
locked_by: OfferLockInfo | null;
|
||||
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
||||
valid_transitions: string[];
|
||||
}
|
||||
|
||||
export const offerDetailOptions = (id: string | undefined) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { 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 { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -10,18 +12,21 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatMultiCurrency,
|
||||
czechPlural,
|
||||
} from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
issuedOrderListOptions,
|
||||
issuedOrderStatsOptions,
|
||||
issuedOrderSuppliersOptions,
|
||||
type IssuedOrder,
|
||||
} from "../lib/queries/issued-orders";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
@@ -44,9 +49,15 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
// DELIBERATE deviation from the Offers status-Tabs row: this page is embedded
|
||||
// under the parent Orders page, which already renders the identical centered
|
||||
// kit <Tabs> (Vydané/Přijaté) directly above this component — a second
|
||||
// same-styled tab row would visually fight it (and the sibling "Přijaté" tab
|
||||
// filters via a Select too). So the status filter stays a Select, with the
|
||||
// "all" entry relabeled to match the offers tabs ("Všechny").
|
||||
const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
|
||||
value: "",
|
||||
label: "Všechny stavy",
|
||||
label: "Všechny",
|
||||
});
|
||||
|
||||
const ViewIcon = (
|
||||
@@ -62,6 +73,19 @@ const ViewIcon = (
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const PdfIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
@@ -107,11 +131,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [status, setStatus] = useState("");
|
||||
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
|
||||
const [page, setPage] = useState(1);
|
||||
// Track first successful load so later refetches (filter/status/page change)
|
||||
// keep the table visible instead of flashing the full-page skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const { data: suppliers } = useQuery(issuedOrderSuppliersOptions());
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the Offers list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
order: IssuedOrder | null;
|
||||
@@ -141,8 +172,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
perPage: 20,
|
||||
status: status || undefined,
|
||||
supplier_id: supplierFilter || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
@@ -154,6 +185,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
issuedOrderStatsOptions({
|
||||
search: debouncedSearch,
|
||||
status: status || undefined,
|
||||
supplier_id: supplierFilter || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
@@ -176,30 +208,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async (o: IssuedOrder) => {
|
||||
// Authenticated open: apiFetch attaches the access token, then we hand the
|
||||
// resulting PDF blob to a window. A raw window.open(url) would be an
|
||||
// unauthenticated top-level navigation and 401 on the token-guarded route.
|
||||
const newWindow = window.open("", "_blank");
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("Nepodařilo se vygenerovat PDF");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba při generování PDF");
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/status/page change) keep the table visible (the Card
|
||||
// dims via isFetching) so it doesn't flash.
|
||||
@@ -234,6 +242,14 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
width: "24%",
|
||||
render: (o) => o.supplier_name || "—",
|
||||
},
|
||||
{
|
||||
key: "order_date",
|
||||
header: "Datum",
|
||||
width: "13%",
|
||||
sortKey: "order_date",
|
||||
mono: true,
|
||||
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
@@ -246,14 +262,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "order_date",
|
||||
header: "Datum",
|
||||
width: "13%",
|
||||
sortKey: "order_date",
|
||||
mono: true,
|
||||
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
@@ -268,39 +276,52 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
align: "right",
|
||||
render: (o) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/orders/issued/${o.id}`)}
|
||||
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"}
|
||||
title={hasPermission("orders.edit") ? "Upravit" : "Detail"}
|
||||
>
|
||||
{ViewIcon}
|
||||
</IconButton>
|
||||
{hasPermission("orders.view") && (
|
||||
render: (o) => {
|
||||
// Offers' semantics: read-only rows (completed/cancelled) get the
|
||||
// view icon, editable rows get the edit icon.
|
||||
const readOnly = o.status === "completed" || o.status === "cancelled";
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleExportPdf(o)}
|
||||
aria-label="PDF"
|
||||
title="PDF"
|
||||
onClick={() => navigate(`/orders/issued/${o.id}`)}
|
||||
aria-label={readOnly ? "Zobrazit" : "Upravit"}
|
||||
title={readOnly ? "Zobrazit" : "Upravit"}
|
||||
>
|
||||
{PdfIcon}
|
||||
{readOnly ? ViewIcon : EditIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("orders.view") && o.po_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`)
|
||||
}
|
||||
aria-label="Zobrazit objednávku"
|
||||
title="Zobrazit objednávku"
|
||||
disabled={pdfLoadingId === o.id}
|
||||
>
|
||||
{pdfLoadingId === o.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -326,12 +347,25 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
return {};
|
||||
};
|
||||
|
||||
// Search/status filter is active → an empty list means "nothing matches"
|
||||
// (no create CTA); a genuinely empty list keeps the create-CTA empty state.
|
||||
const isFiltered = !!debouncedSearch || !!status;
|
||||
// Search/status/supplier filter is active → an empty list means "nothing
|
||||
// matches" (no create CTA); a genuinely empty list keeps the create-CTA
|
||||
// empty state.
|
||||
const isFiltered = !!debouncedSearch || !!status || !!supplierFilter;
|
||||
|
||||
const total = pagination?.total ?? orders.length;
|
||||
const countLine = `${total} ${czechPlural(
|
||||
total,
|
||||
"objednávka",
|
||||
"objednávky",
|
||||
"objednávek",
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
{countLine}
|
||||
</Typography>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
@@ -354,6 +388,22 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={supplierFilter === "" ? "" : String(supplierFilter)}
|
||||
onChange={(value) => {
|
||||
setSupplierFilter(value ? Number(value) : "");
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všichni dodavatelé" },
|
||||
...(suppliers ?? []).map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
@@ -367,14 +417,19 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
isFiltered ? (
|
||||
<EmptyState title="Žádné objednávky neodpovídají filtru." />
|
||||
<EmptyState
|
||||
title="Žádné objednávky neodpovídají filtru"
|
||||
description="Zkuste změnit filtr nebo hledaný výraz."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím žádné vydané objednávky."
|
||||
description={
|
||||
hasPermission("orders.create")
|
||||
? "Vytvořte první tlačítkem „Vytvořit objednávku."
|
||||
: undefined
|
||||
action={
|
||||
hasPermission("orders.create") ? (
|
||||
<Button component={RouterLink} to="/orders/issued/new">
|
||||
Vytvořit objednávku
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -415,11 +470,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
title="Smazat objednávku"
|
||||
message={
|
||||
deleteConfirm.order
|
||||
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.po_number || ""}"? Tato akce je nevratná.`
|
||||
? `Opravdu chcete smazat objednávku „${documentNumberLabel(deleteConfirm.order.po_number)}“? Budou smazány i všechny položky. Tato akce je nevratná.`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,12 @@ import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
offerListOptions,
|
||||
offerStatsOptions,
|
||||
offerCustomersOptions,
|
||||
type Quotation,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
|
||||
label: "Všechny",
|
||||
}).filter((o) => o.value !== "expired");
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
const TemplatesIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -336,25 +324,17 @@ export default function Offers() {
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [invalidateConfirm, setInvalidateConfirm] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [invalidating, setInvalidating] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const [duplicating, setDuplicating] = useState<number | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the issued list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
const [orderModal, setOrderModal] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
@@ -490,13 +470,10 @@ export default function Offers() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.quotation) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -512,37 +489,6 @@ export default function Offers() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (quotation: Quotation) => {
|
||||
if (pdfLoading) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
setPdfLoading(quotation.id);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/offers/${quotation.id}/file`,
|
||||
);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
blobUrlRef.current = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrlRef.current;
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setPdfLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/customer/tab/page change) keep the table visible (the
|
||||
// Card dims via isFetching) so it doesn't flash.
|
||||
@@ -573,6 +519,7 @@ export default function Offers() {
|
||||
header: "Číslo",
|
||||
width: "13%",
|
||||
sortKey: "quotation_number",
|
||||
mono: true,
|
||||
render: (q) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
@@ -597,7 +544,7 @@ export default function Offers() {
|
||||
{
|
||||
key: "customer_name",
|
||||
header: "Zákazník",
|
||||
width: "13%",
|
||||
width: "16%",
|
||||
render: (q) => q.customer_name || "—",
|
||||
},
|
||||
{
|
||||
@@ -637,17 +584,10 @@ export default function Offers() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
header: "Měna",
|
||||
width: "7%",
|
||||
sortKey: "currency",
|
||||
render: (q) => <StatusChip label={q.currency} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "11%",
|
||||
width: "12%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
@@ -693,7 +633,9 @@ export default function Offers() {
|
||||
{OrderViewIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
!readOnly &&
|
||||
// Only active offers can spawn an order — the server transition
|
||||
// guard rejects drafts/invalidated with a 400.
|
||||
q.status === "active" &&
|
||||
hasPermission("orders.create") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -714,15 +656,20 @@ export default function Offers() {
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{hasPermission("offers.view") && (
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("offers.view") && q.quotation_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(q)}
|
||||
onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
|
||||
aria-label="Zobrazit nabídku"
|
||||
title="Zobrazit nabídku"
|
||||
disabled={pdfLoading === q.id}
|
||||
disabled={pdfLoadingId === q.id}
|
||||
>
|
||||
{pdfLoading === q.id ? <CircularProgress size={16} /> : PdfIcon}
|
||||
{pdfLoadingId === q.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("offers.delete") && (
|
||||
@@ -927,10 +874,10 @@ export default function Offers() {
|
||||
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat nabídku"
|
||||
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
message={`Opravdu chcete smazat nabídku „${documentNumberLabel(deleteConfirm.quotation?.quotation_number)}“? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
loading={deleteOfferMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -938,7 +885,7 @@ export default function Offers() {
|
||||
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleInvalidate}
|
||||
title="Zneplatnit nabídku"
|
||||
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
message={`Opravdu chcete zneplatnit nabídku „${documentNumberLabel(invalidateConfirm.quotation?.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
confirmText="Zneplatnit"
|
||||
confirmVariant="danger"
|
||||
loading={invalidating}
|
||||
|
||||
@@ -198,7 +198,8 @@ export default function OffersCustomers() {
|
||||
? `${API_BASE}/customers/${editingCustomer.id}`
|
||||
: `${API_BASE}/customers`,
|
||||
method: () => (editingCustomer ? "PUT" : "POST"),
|
||||
invalidate: ["offer-customers", "offers"],
|
||||
// Broad domain key — covers the ["offers","customers"] picker query too.
|
||||
invalidate: ["offers"],
|
||||
onSuccess: (data) => {
|
||||
closeModal();
|
||||
setTimeout(
|
||||
@@ -214,7 +215,8 @@ export default function OffersCustomers() {
|
||||
>({
|
||||
url: (id) => `${API_BASE}/customers/${id}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["offer-customers", "offers"],
|
||||
// Broad domain key — covers the ["offers","customers"] picker query too.
|
||||
invalidate: ["offers"],
|
||||
onSuccess: (data) => {
|
||||
setDeleteConfirm({ show: false, customer: null });
|
||||
alert.success(data?.message || "Zákazník byl smazán");
|
||||
|
||||
Reference in New Issue
Block a user