Files
app/src/admin/pages/WarehouseSuppliers.tsx
BOHA 6a22195c7d feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer
Issued orders are purchase orders WE send - they must pick from suppliers
(sklad_suppliers), not from customers. Per user decision customer_id was
REPLACED (not kept alongside): migration drops issued_orders.customer_id and
adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty
- the feature is days old; re-point them in the UI).

- service: input/filters/search (suppliers.name + ico)/enrichment/detail all
  supplier-based; create validates the supplier inside the transaction and
  update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500;
  detail returns a minimal supplier field set (no internal notes leak)
- routes: supplier_id on list + stats; new GET /issued-orders/suppliers
  lookup (orders.view/create/edit guard - orders users lack warehouse.manage
  which guards the warehouse suppliers CRUD), active suppliers only,
  name+id ordering
- PDF: Dodavatel block now renders the supplier (name, newline-split address,
  IC/DIC), layout and both language label sets unchanged
- frontend: new SupplierPicker kit component (CustomerPicker untouched),
  IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the
  picker keeps a fallback option for orders whose supplier was later
  deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so
  the picker can't go stale
- tests: issued-orders suite switched to supplier fixtures + new coverage
  (lookup shape + 403, nonexistent supplier 400, PDF supplier block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:06 +02:00

522 lines
14 KiB
TypeScript

import { useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
TextField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin/warehouse/suppliers";
interface SupplierForm {
name: string;
ico: string;
dic: string;
contact_person: string;
email: string;
phone: string;
address: string;
notes: string;
}
const PER_PAGE = 20;
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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 DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function WarehouseSuppliers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const {
items: suppliers,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<WarehouseSupplier>(
warehouseSupplierListOptions({
search: debouncedSearch || undefined,
page,
perPage: PER_PAGE,
}),
);
const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] =
useState<WarehouseSupplier | null>(null);
const [form, setForm] = useState<SupplierForm>({
name: "",
ico: "",
dic: "",
contact_person: "",
email: "",
phone: "",
address: "",
notes: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [deactivateConfirm, setDeactivateConfirm] = useState<{
show: boolean;
supplier: WarehouseSupplier | null;
}>({ show: false, supplier: null });
const submitMutation = useApiMutation<
SupplierForm,
{ id?: number; message?: string }
>({
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen");
},
});
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán");
},
});
// id is captured from the mutation variable (built into the URL), so it stays
// correct even when called immediately after a state update. The non-strict
// UpdateSupplierSchema strips the `id` field from the body.
const toggleActiveMutation = useApiMutation<
{ id: number; is_active: boolean },
{ message?: string }
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;
const openCreateModal = () => {
setEditingSupplier(null);
setForm({
name: "",
ico: "",
dic: "",
contact_person: "",
email: "",
phone: "",
address: "",
notes: "",
});
setErrors({});
setShowModal(true);
};
const openEditModal = (supplier: WarehouseSupplier) => {
setEditingSupplier(supplier);
setForm({
name: supplier.name,
ico: supplier.ico || "",
dic: supplier.dic || "",
contact_person: supplier.contact_person || "",
email: supplier.email || "",
phone: supplier.phone || "",
address: supplier.address || "",
notes: supplier.notes || "",
});
setErrors({});
setShowModal(true);
};
const handleSubmit = async () => {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Zadejte název dodavatele";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
try {
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDeactivate = async () => {
if (!deactivateConfirm.supplier) return;
try {
await deleteMutation.mutateAsync(deactivateConfirm.supplier.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const toggleActive = async (supplier: WarehouseSupplier) => {
try {
await toggleActiveMutation.mutateAsync({
id: supplier.id,
is_active: !supplier.is_active,
});
alert.success(
supplier.is_active
? "Dodavatel byl deaktivován"
: "Dodavatel byl aktivován",
);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (isPending) {
return <LoadingState />;
}
const total = pagination?.total ?? suppliers.length;
const subtitle = `${total} ${
total === 1
? "dodavatel"
: total >= 2 && total <= 4
? "dodavatelé"
: "dodavatelů"
}`;
const columns: DataColumn<WarehouseSupplier>[] = [
{
key: "name",
header: "Název",
width: "20%",
bold: true,
render: (s) => s.name,
},
{
key: "ico",
header: "IČO",
width: "11%",
mono: true,
render: (s) => s.ico || "—",
},
{
key: "dic",
header: "DIČ",
width: "11%",
mono: true,
render: (s) => s.dic || "—",
},
{
key: "contact_person",
header: "Kontaktní osoba",
width: "16%",
render: (s) => s.contact_person || "—",
},
{
key: "email",
header: "E-mail",
width: "16%",
render: (s) => s.email || "—",
},
{
key: "phone",
header: "Telefon",
width: "12%",
mono: true,
render: (s) => s.phone || "—",
},
{
key: "status",
header: "Stav",
width: "8%",
render: (s) => (
<StatusChip
label={s.is_active ? "Aktivní" : "Neaktivní"}
color={s.is_active ? "success" : "default"}
onClick={() => toggleActive(s)}
/>
),
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: (s) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(s)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeactivateConfirm({ show: true, supplier: s })}
aria-label={s.is_active ? "Deaktivovat" : "Smazat"}
title={s.is_active ? "Deaktivovat" : "Smazat"}
>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<PageEnter>
<PageHeader
title="Dodavatelé"
subtitle={subtitle}
actions={
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat dodavatele
</Button>
}
/>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat dodavatele..."
fullWidth
/>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<WarehouseSupplier>
columns={columns}
rows={suppliers}
rowKey={(s) => s.id}
rowInactive={(s) => !s.is_active}
empty={
search ? (
<EmptyState title={`Žádní dodavatelé pro „${search}".`} />
) : (
<EmptyState
title="Zatím nejsou žádní dodavatelé."
action={
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat prvního dodavatele
</Button>
}
/>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={submitMutation.isPending}
>
<Field label="Název" required error={errors.name}>
<TextField
value={form.name}
error={!!errors.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
placeholder="Název dodavatele"
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="IČO">
<TextField
value={form.ico}
onChange={(e) => setForm({ ...form, ico: e.target.value })}
placeholder="12345678"
/>
</Field>
<Field label="DIČ">
<TextField
value={form.dic}
onChange={(e) => setForm({ ...form, dic: e.target.value })}
placeholder="CZ12345678"
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Kontaktní osoba">
<TextField
value={form.contact_person}
onChange={(e) =>
setForm({ ...form, contact_person: e.target.value })
}
placeholder="Jan Novák"
/>
</Field>
<Field label="E-mail">
<TextField
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="info@firma.cz"
/>
</Field>
</Box>
<Field label="Telefon">
<TextField
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+420 123 456 789"
/>
</Field>
<Field label="Adresa">
<TextField
multiline
minRows={3}
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="Ulice, město, PSČ"
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Volitelné poznámky"
/>
</Field>
</Modal>
{/* Deactivate/Delete Confirmation */}
<ConfirmDialog
isOpen={deactivateConfirm.show}
onClose={() => setDeactivateConfirm({ show: false, supplier: null })}
onConfirm={handleDeactivate}
title={
deactivateConfirm.supplier?.is_active
? "Deaktivovat dodavatele"
: "Smazat dodavatele"
}
message={
deactivateConfirm.supplier
? deactivateConfirm.supplier.is_active
? `Opravdu chcete deaktivovat dodavatele "${deactivateConfirm.supplier.name}"?`
: `Opravdu chcete smazat dodavatele "${deactivateConfirm.supplier.name}"? Tato akce je nevratná.`
: ""
}
confirmText={
deactivateConfirm.supplier?.is_active ? "Deaktivovat" : "Smazat"
}
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</PageEnter>
);
}