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>
This commit is contained in:
BOHA
2026-06-10 11:29:06 +02:00
parent 74ce24e3fa
commit 6a22195c7d
15 changed files with 598 additions and 112 deletions

View File

@@ -4,8 +4,8 @@ import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface IssuedOrder {
id: number;
po_number: string | null;
customer_id: number | null;
customer_name: string | null;
supplier_id: number | null;
supplier_name: string | null;
status: string;
currency: string | null;
order_date: string | null;
@@ -14,6 +14,17 @@ export interface IssuedOrder {
total: number;
}
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
export interface Supplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
address: string | null;
email: string | null;
phone: string | null;
}
export interface IssuedOrderItem {
id?: number;
description: string | null;
@@ -37,10 +48,19 @@ export interface IssuedOrderDetail extends IssuedOrder {
notes: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
customer: Record<string, unknown> | null;
supplier: Record<string, unknown> | null;
valid_transitions: string[];
}
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
export const issuedOrderSuppliersOptions = () =>
queryOptions({
queryKey: ["issued-orders", "suppliers"],
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
staleTime: 2 * 60_000,
});
export const issuedOrderListOptions = (filters: {
search?: string;
sort?: string;

View File

@@ -41,8 +41,11 @@ import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { issuedOrderDetailOptions } from "../lib/queries/issued-orders";
import {
issuedOrderDetailOptions,
issuedOrderSuppliersOptions,
type Supplier,
} from "../lib/queries/issued-orders";
import { companySettingsOptions } from "../lib/queries/settings";
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -58,7 +61,7 @@ import {
ConfirmDialog,
LoadingState,
PageEnter,
CustomerPicker,
SupplierPicker,
headerActionsSx,
} from "../ui";
import {
@@ -157,8 +160,8 @@ interface OrderItem {
}
interface OrderForm {
customer_id: number | null;
customer_name: string;
supplier_id: number | null;
supplier_name: string;
currency: string;
apply_vat: boolean;
vat_rate: number;
@@ -506,8 +509,8 @@ export default function IssuedOrderDetail() {
);
const [form, setForm] = useState<OrderForm>({
customer_id: null,
customer_name: "",
supplier_id: null,
supplier_name: "",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
@@ -551,8 +554,34 @@ export default function IssuedOrderDetail() {
const [deleting, setDeleting] = useState(false);
// ─── Queries ───
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
const suppliersQuery = useQuery(issuedOrderSuppliersOptions());
// The lookup returns ACTIVE suppliers only, but a saved order may reference
// a since-deactivated one — without a fallback option the picker would
// resolve to nothing and render the counterparty blank. The fallback is
// built from the hydrated form state (supplier_name comes from the detail
// response), so the name stays visible and a re-save keeps the same id.
const suppliers = useMemo<Supplier[]>(() => {
const activeSuppliers = suppliersQuery.data ?? [];
if (
form.supplier_id != null &&
!activeSuppliers.some((s: Supplier) => s.id === form.supplier_id)
) {
return [
...activeSuppliers,
{
id: form.supplier_id,
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
ico: null,
dic: null,
address: null,
email: null,
phone: null,
},
];
}
return activeSuppliers;
}, [suppliersQuery.data, form.supplier_id, form.supplier_name]);
const companySettings = useQuery(companySettingsOptions()).data;
// Configurable currency list from company settings (falls back to the
@@ -576,13 +605,13 @@ export default function IssuedOrderDetail() {
// ─── Edit mode: hydrate form from detail (once) ───
useEffect(() => {
if (!isEdit || dataReady) return;
if (detailQuery.isLoading || customersQuery.isLoading) return;
if (detailQuery.isLoading || suppliersQuery.isLoading) return;
if (!detailQuery.data) return;
const d = detailQuery.data;
setForm({
customer_id: d.customer_id ?? null,
customer_name: d.customer_name ?? "",
supplier_id: d.supplier_id ?? null,
supplier_name: d.supplier_name ?? "",
currency: d.currency || "CZK",
apply_vat: d.apply_vat !== false,
vat_rate: numberOr(d.vat_rate, 21),
@@ -619,13 +648,13 @@ export default function IssuedOrderDetail() {
dataReady,
detailQuery.isLoading,
detailQuery.data,
customersQuery.isLoading,
suppliersQuery.isLoading,
]);
// ─── Create mode: set the previewed PO number + default issued_by ───
useEffect(() => {
if (isEdit || dataReady) return;
if (nextNumberQuery.isLoading || customersQuery.isLoading) return;
if (nextNumberQuery.isLoading || suppliersQuery.isLoading) return;
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
setDataReady(true);
}, [
@@ -633,7 +662,7 @@ export default function IssuedOrderDetail() {
dataReady,
nextNumberQuery.isLoading,
nextNumberQuery.data,
customersQuery.isLoading,
suppliersQuery.isLoading,
]);
// Keep the displayed PO number in sync once it becomes available — a draft
@@ -715,20 +744,20 @@ export default function IssuedOrderDetail() {
});
};
const selectCustomer = (id: number | null) => {
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
const selectSupplier = (id: number | null) => {
const s = id != null ? suppliers.find((x: Supplier) => x.id === id) : null;
setForm((prev) => ({
...prev,
customer_id: id,
customer_name: c?.name || "",
supplier_id: id,
supplier_name: s?.name || "",
}));
setErrors((prev) => ({ ...prev, customer_id: "" }));
setErrors((prev) => ({ ...prev, supplier_id: "" }));
};
// ─── Submit (create + edit) ───
const handleSubmit = async (targetStatus?: string) => {
const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
if (!form.order_date) newErrors.order_date = "Zadejte datum";
if (items.length === 0 || items.every((i) => !i.description.trim())) {
newErrors.items = "Přidejte alespoň jednu položku";
@@ -740,7 +769,7 @@ export default function IssuedOrderDetail() {
setSavingAction(targetStatus ?? "save");
try {
const payload: Record<string, unknown> = {
customer_id: form.customer_id,
supplier_id: form.supplier_id,
currency: form.currency,
vat_rate: form.vat_rate,
apply_vat: form.apply_vat,
@@ -1108,13 +1137,13 @@ export default function IssuedOrderDetail() {
gap: 2,
}}
>
<Field label="Dodavatel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
<Field label="Dodavatel" error={errors.supplier_id} required>
<SupplierPicker
suppliers={suppliers}
value={form.supplier_id}
onChange={selectSupplier}
disabled={!editable}
error={errors.customer_id}
error={errors.supplier_id}
placeholder="Vyberte dodavatele…"
/>
</Field>

View File

@@ -229,10 +229,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
),
},
{
key: "customer_name",
key: "supplier_name",
header: "Dodavatel",
width: "24%",
render: (o) => o.customer_name || "—",
render: (o) => o.supplier_name || "—",
},
{
key: "status",

View File

@@ -137,7 +137,9 @@ export default function WarehouseSuppliers() {
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
invalidate: ["warehouse"],
// 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");
@@ -147,7 +149,9 @@ export default function WarehouseSuppliers() {
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
// 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");
@@ -163,7 +167,9 @@ export default function WarehouseSuppliers() {
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
// 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 />;

View File

@@ -0,0 +1,88 @@
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
import MuiTextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { Supplier } from "../lib/queries/issued-orders";
interface SupplierPickerProps {
suppliers: Supplier[];
/** Selected supplier id (FK), or null when none chosen. */
value: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
/**
* When set, draws the input in its error state (red border). The error TEXT
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
* render helperText here to avoid duplicating the message.
*/
error?: string;
placeholder?: string;
/** Optional id forwarded by <Field> for label association. */
id?: string;
}
// Search matches the supplier name AND the IČO, so typing either finds the
// record. Match on a stringified "name + ico" so MUI's default
// case-insensitive substring filtering covers both.
const filterSuppliers = createFilterOptions<Supplier>({
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
});
/**
* Searchable supplier picker for issued orders (purchase orders) — a
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
* renders bare (no label/error text); wrap it in the page's
* <Field label error> for the label + error line.
*/
export default function SupplierPicker({
suppliers,
value,
onChange,
disabled,
error,
placeholder,
id,
}: SupplierPickerProps) {
const selected = suppliers.find((s) => s.id === value) ?? null;
return (
<Autocomplete<Supplier>
options={suppliers}
value={selected}
onChange={(_, opt) => onChange(opt ? opt.id : null)}
getOptionLabel={(s) => s?.name ?? ""}
isOptionEqualToValue={(o, v) => o.id === v.id}
filterOptions={filterSuppliers}
disabled={disabled}
size="small"
fullWidth
autoHighlight
renderOption={(props, s) => {
const { key, ...rest } = props as typeof props & { key: string };
return (
<Box component="li" key={key} {...rest}>
<Box>
{s.name}
{s.ico && (
<Typography
variant="caption"
sx={{ display: "block", color: "text.secondary" }}
>
: {s.ico}
</Typography>
)}
</Box>
</Box>
);
}}
renderInput={(params) => (
<MuiTextField
{...params}
id={id}
placeholder={placeholder ?? "Vyberte dodavatele…"}
error={!!error}
/>
)}
/>
);
}

View File

@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
export { default as Select } from "./Select";
export type { SelectOption } from "./Select";
export { default as CustomerPicker } from "./CustomerPicker";
export { default as SupplierPicker } from "./SupplierPicker";
export { default as StatusChip } from "./StatusChip";
export { CheckboxField } from "./Checkbox";
export { default as Alert } from "./Alert";