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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
Reference in New Issue
Block a user