From db7a5c3d153daaf49999091bbcd7e33b71bf9c93 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 20:11:22 +0200 Subject: [PATCH] feat(suppliers)!: full customers model - structured address + custom fields The dodavatele modal is now an exact mirror of the customers modal (minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme, ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md). Two migrations on sklad_suppliers: - structured street/city/postal_code/country replace the free-text address blob (best-effort split: street / PSC regex / city) - custom_fields LONGTEXT replaces contact_person/email/phone/notes; existing values are preserved as labeled custom fields The encode/decode of the custom_fields blob is shared with customers via the new utils/custom-fields.ts. The PO PDF supplier block renders the structured address + custom-field lines like the customer block; the supplier picker/detail selects and types follow. Legacy clients sending the dropped keys are silently stripped (tested). Suppliers list shows Ulice/Mesto instead of the dropped contact columns; search covers name/ico/city. BREAKING CHANGE: sklad_suppliers.address, contact_person, email, phone, notes columns dropped (data migrated); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 --- .../migration.sql | 28 ++ .../migration.sql | 43 ++ prisma/schema.prisma | 24 +- src/__tests__/issued-orders.test.ts | 37 +- src/__tests__/schema-nan.test.ts | 31 +- src/admin/lib/queries/issued-orders.ts | 7 +- src/admin/lib/queries/warehouse.ts | 17 +- src/admin/pages/IssuedOrderDetail.tsx | 7 +- src/admin/pages/WarehouseSuppliers.tsx | 374 +++++++++++++----- src/routes/admin/customers.ts | 42 +- src/routes/admin/issued-orders-pdf.ts | 61 ++- src/routes/admin/issued-orders.ts | 7 +- src/routes/admin/warehouse.ts | 52 ++- src/schemas/warehouse.schema.ts | 25 +- src/services/issued-orders.service.ts | 7 +- src/utils/custom-fields.ts | 48 +++ 16 files changed, 578 insertions(+), 232 deletions(-) create mode 100644 prisma/migrations/20260610200000_supplier_structured_address/migration.sql create mode 100644 prisma/migrations/20260610203000_supplier_custom_fields/migration.sql create mode 100644 src/utils/custom-fields.ts diff --git a/prisma/migrations/20260610200000_supplier_structured_address/migration.sql b/prisma/migrations/20260610200000_supplier_structured_address/migration.sql new file mode 100644 index 0000000..247dfb8 --- /dev/null +++ b/prisma/migrations/20260610200000_supplier_structured_address/migration.sql @@ -0,0 +1,28 @@ +-- Suppliers get a structured address (street/city/postal_code/country) like +-- customers; the single free-text `address` blob is dropped. Existing data is +-- split best-effort: newlines normalized to ", ", first comma segment -> +-- street, the PSC (3+2 digits) -> postal_code, the remainder -> city. +-- Unparseable one-segment addresses keep their full text in `street`. + +-- AlterTable: add the structured columns +ALTER TABLE `sklad_suppliers` + ADD COLUMN `street` VARCHAR(255) NULL AFTER `phone`, + ADD COLUMN `city` VARCHAR(255) NULL AFTER `street`, + ADD COLUMN `postal_code` VARCHAR(20) NULL AFTER `city`, + ADD COLUMN `country` VARCHAR(100) NULL AFTER `postal_code`; + +-- Preserve data: best-effort split of the legacy free-text address +UPDATE `sklad_suppliers` +SET + `street` = NULLIF(TRIM(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)), ''), + `postal_code` = REGEXP_SUBSTR(`address`, '[0-9]{3}[ ]?[0-9]{2}'), + `city` = NULLIF(TRIM(BOTH ',' FROM TRIM(REGEXP_REPLACE( + SUBSTRING( + REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), + CHAR_LENGTH(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)) + 2 + ), + '[0-9]{3}[ ]?[0-9]{2}', ''))), '') +WHERE `address` IS NOT NULL AND `address` <> ''; + +-- AlterTable: drop the legacy blob +ALTER TABLE `sklad_suppliers` DROP COLUMN `address`; diff --git a/prisma/migrations/20260610203000_supplier_custom_fields/migration.sql b/prisma/migrations/20260610203000_supplier_custom_fields/migration.sql new file mode 100644 index 0000000..9a51916 --- /dev/null +++ b/prisma/migrations/20260610203000_supplier_custom_fields/migration.sql @@ -0,0 +1,43 @@ +-- Suppliers become a full mirror of the customers model: dedicated +-- contact_person/email/phone/notes columns are replaced by the same +-- custom_fields JSON blob customers use ({"fields":[{name,value,showLabel}], +-- "field_order":[]}). Existing contact data is preserved as custom fields. + +-- AlterTable: add the custom_fields blob +ALTER TABLE `sklad_suppliers` ADD COLUMN `custom_fields` LONGTEXT NULL AFTER `country`; + +-- Seed an empty container for rows that carry any contact data +UPDATE `sklad_suppliers` +SET `custom_fields` = JSON_OBJECT('fields', JSON_ARRAY(), 'field_order', JSON_ARRAY()) +WHERE COALESCE(`contact_person`, '') <> '' + OR COALESCE(`email`, '') <> '' + OR COALESCE(`phone`, '') <> '' + OR COALESCE(`notes`, '') <> ''; + +-- Preserve data: each legacy column becomes one labeled custom field +UPDATE `sklad_suppliers` +SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields', + JSON_OBJECT('name', 'Kontaktní osoba', 'value', `contact_person`, 'showLabel', TRUE)) +WHERE COALESCE(`contact_person`, '') <> ''; + +UPDATE `sklad_suppliers` +SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields', + JSON_OBJECT('name', 'E-mail', 'value', `email`, 'showLabel', TRUE)) +WHERE COALESCE(`email`, '') <> ''; + +UPDATE `sklad_suppliers` +SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields', + JSON_OBJECT('name', 'Telefon', 'value', `phone`, 'showLabel', TRUE)) +WHERE COALESCE(`phone`, '') <> ''; + +UPDATE `sklad_suppliers` +SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields', + JSON_OBJECT('name', 'Poznámky', 'value', `notes`, 'showLabel', TRUE)) +WHERE COALESCE(`notes`, '') <> ''; + +-- AlterTable: drop the dedicated columns +ALTER TABLE `sklad_suppliers` + DROP COLUMN `contact_person`, + DROP COLUMN `email`, + DROP COLUMN `phone`, + DROP COLUMN `notes`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6d97827..e9f7718 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -804,18 +804,18 @@ model sklad_categories { } model sklad_suppliers { - id Int @id @default(autoincrement()) - name String @db.VarChar(255) - ico String? @db.VarChar(20) - dic String? @db.VarChar(20) - contact_person String? @db.VarChar(255) - email String? @db.VarChar(255) - phone String? @db.VarChar(50) - address String? @db.Text - notes String? @db.Text - is_active Boolean @default(true) - created_at DateTime? @default(now()) @db.DateTime(0) - modified_at DateTime? @db.DateTime(0) + id Int @id @default(autoincrement()) + name String @db.VarChar(255) + ico String? @db.VarChar(20) + dic String? @db.VarChar(20) + street String? @db.VarChar(255) + city String? @db.VarChar(255) + postal_code String? @db.VarChar(20) + country String? @db.VarChar(100) + custom_fields String? @db.LongText + is_active Boolean @default(true) + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) receipts sklad_receipts[] issued_orders issued_orders[] diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index c5aa6fa..c1847bf 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -450,9 +450,20 @@ describe("GET /api/admin/issued-orders/suppliers", () => { const row = body.data.find((s: { id: number }) => s.id === active.id); expect(row.name).toBe("io_test_Aktivní dodavatel"); expect(row.ico).toBe("12345678"); - // Lightweight lookup shape — all picker fields present. + // Lightweight lookup shape — all picker fields present (structured + // address since the 2026-06 supplier customers-model split; the + // email/phone columns were dropped in favour of custom_fields). expect(Object.keys(row).sort()).toEqual( - ["address", "dic", "email", "ico", "id", "name", "phone"].sort(), + [ + "city", + "country", + "dic", + "ico", + "id", + "name", + "postal_code", + "street", + ].sort(), ); }); @@ -656,12 +667,15 @@ describe("renderIssuedOrderHtml", () => { const issuer = { name: "Jan Novák" }; - // sklad_suppliers shape: single Text address blob + ico/dic columns. + // sklad_suppliers shape: structured address + ico/dic columns. const supplier = { name: "Dodavatel s.r.o.", ico: "12345678", dic: "CZ12345678", - address: "Průmyslová 5\n190 00 Praha", + street: "Průmyslová 5", + city: "Praha", + postal_code: "190 00", + country: "Česká republika", }; it("renders the PO number, items, and both party names (PO direction)", () => { @@ -685,7 +699,7 @@ describe("renderIssuedOrderHtml", () => { expect(html).toContain("Odběratel"); }); - it("renders the supplier address (split on newlines) plus IČO and DIČ", () => { + it("renders the structured supplier address plus IČO and DIČ", () => { const html = renderIssuedOrderHtml( order, items, @@ -694,25 +708,26 @@ describe("renderIssuedOrderHtml", () => { "cs", issuer, ); - // The single Text address blob becomes one address line per newline. + // street / "PSČ Město" / country — one address line each. expect(html).toContain('
Průmyslová 5
'); expect(html).toContain('
190 00 Praha
'); + expect(html).toContain('
Česká republika
'); expect(html).toContain("IČ: 12345678"); expect(html).toContain("DIČ: CZ12345678"); }); - it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => { + it("skips empty address parts and missing IČO/DIČ", () => { const html = renderIssuedOrderHtml( order, items, - { name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" }, + { name: "Jednořádkový", street: "Ulice 1", city: "Praha" }, null, "cs", issuer, ); - expect(html).toContain( - '
Ulice 1, 100 00 Praha
', - ); + expect(html).toContain('
Ulice 1
'); + // City without PSČ renders alone, no stray separator. + expect(html).toContain('
Praha
'); expect(html).not.toContain("IČ: "); expect(html).not.toContain("DIČ: "); }); diff --git a/src/__tests__/schema-nan.test.ts b/src/__tests__/schema-nan.test.ts index c505e42..3299017 100644 --- a/src/__tests__/schema-nan.test.ts +++ b/src/__tests__/schema-nan.test.ts @@ -240,18 +240,25 @@ describe("schema hardening — does not reject previously-valid input", () => { ).toBe(false); }); - it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => { - expect( - CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success, - ).toBe(true); - expect( - CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" }) - .success, - ).toBe(true); - expect( - CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" }) - .success, - ).toBe(false); + it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => { + // email/phone/contact_person/notes/address columns were replaced by the + // customers-model custom_fields blob (2026-06); legacy clients still + // sending them must not 400 — z.object strips unknown keys. + const parsed = CreateSupplierSchema.safeParse({ + name: "Dodavatel", + email: "x@y.cz", + phone: "123", + contact_person: "Jan", + notes: "n", + address: "Ulice 1", + custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }], + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect("email" in parsed.data).toBe(false); + expect("address" in parsed.data).toBe(false); + expect(parsed.data.custom_fields).toHaveLength(1); + } }); it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => { diff --git a/src/admin/lib/queries/issued-orders.ts b/src/admin/lib/queries/issued-orders.ts index ff3d954..586c197 100644 --- a/src/admin/lib/queries/issued-orders.ts +++ b/src/admin/lib/queries/issued-orders.ts @@ -25,9 +25,10 @@ export interface Supplier { name: string; ico: string | null; dic: string | null; - address: string | null; - email: string | null; - phone: string | null; + street: string | null; + city: string | null; + postal_code: string | null; + country: string | null; } export interface IssuedOrderItem { diff --git a/src/admin/lib/queries/warehouse.ts b/src/admin/lib/queries/warehouse.ts index d8bdf81..3c39646 100644 --- a/src/admin/lib/queries/warehouse.ts +++ b/src/admin/lib/queries/warehouse.ts @@ -157,16 +157,23 @@ export interface WarehouseLocation { is_active: boolean; } +export interface WarehouseSupplierCustomField { + name: string; + value: string; + showLabel: boolean; + _key?: string; +} + export interface WarehouseSupplier { id: number; name: string; ico: string | null; dic: string | null; - contact_person: string | null; - email: string | null; - phone: string | null; - address: string | null; - notes: string | null; + street: string | null; + city: string | null; + postal_code: string | null; + country: string | null; + custom_fields?: WarehouseSupplierCustomField[]; is_active: boolean; } diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index 7152140..843f4f7 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -207,9 +207,10 @@ export default function IssuedOrderDetail() { name: form.supplier_name || `Dodavatel #${form.supplier_id}`, ico: null, dic: null, - address: null, - email: null, - phone: null, + street: null, + city: null, + postal_code: null, + country: null, }, ]; } diff --git a/src/admin/pages/WarehouseSuppliers.tsx b/src/admin/pages/WarehouseSuppliers.tsx index d62884a..19faa08 100644 --- a/src/admin/pages/WarehouseSuppliers.tsx +++ b/src/admin/pages/WarehouseSuppliers.tsx @@ -1,15 +1,17 @@ -import { useState } from "react"; +import { useState, useRef } 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 AresAdornment, { type AresCompany } from "../components/AresLookup"; import useDebounce from "../hooks/useDebounce"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { warehouseSupplierListOptions, type WarehouseSupplier, + type WarehouseSupplierCustomField, } from "../lib/queries/warehouse"; import { useApiMutation } from "../lib/queries/mutations"; import { @@ -21,6 +23,7 @@ import { ConfirmDialog, Field, TextField, + CheckboxField, StatusChip, PageHeader, PageEnter, @@ -32,17 +35,29 @@ import { const API_BASE = "/api/admin/warehouse/suppliers"; +// Mirror of the customers modal (minus the PDF field-order picker): the same +// company fields + "Vlastní pole"; contact person/e-mail/phone live as custom +// fields since the dedicated columns were dropped. interface SupplierForm { name: string; ico: string; dic: string; - contact_person: string; - email: string; - phone: string; - address: string; - notes: string; + street: string; + city: string; + postal_code: string; + country: string; } +const EMPTY_SUPPLIER_FORM: SupplierForm = { + name: "", + ico: "", + dic: "", + street: "", + city: "", + postal_code: "", + country: "", +}; + const PER_PAGE = 20; const PlusIcon = ( @@ -88,6 +103,32 @@ const DeleteIcon = ( ); +const SmallPlusIcon = ( + + + + +); +const RemoveIcon = ( + + + + +); export default function WarehouseSuppliers() { const alert = useAlert(); @@ -113,16 +154,11 @@ export default function WarehouseSuppliers() { const [showModal, setShowModal] = useState(false); const [editingSupplier, setEditingSupplier] = useState(null); - const [form, setForm] = useState({ - name: "", - ico: "", - dic: "", - contact_person: "", - email: "", - phone: "", - address: "", - notes: "", - }); + const [form, setForm] = useState({ ...EMPTY_SUPPLIER_FORM }); + const [customFields, setCustomFields] = useState< + WarehouseSupplierCustomField[] + >([]); + const customFieldKeyCounter = useRef(0); const [errors, setErrors] = useState>({}); const [deactivateConfirm, setDeactivateConfirm] = useState<{ @@ -131,7 +167,13 @@ export default function WarehouseSuppliers() { }>({ show: false, supplier: null }); const submitMutation = useApiMutation< - SupplierForm, + SupplierForm & { + custom_fields: Array<{ + name: string; + value: string; + showLabel?: boolean; + }>; + }, { id?: number; message?: string } >({ url: () => @@ -174,18 +216,26 @@ export default function WarehouseSuppliers() { if (!hasPermission("warehouse.manage")) return ; + // ARES prefill — overwrite the company fields with registry data (the + // button is an explicit user action, so overwriting is the expected UX). + const fillFromAres = (c: AresCompany) => { + setForm((prev) => ({ + ...prev, + name: c.name || prev.name, + ico: c.ico, + dic: c.dic || "", + street: c.street, + city: c.city, + postal_code: c.postal_code, + country: c.country, + })); + setErrors((prev) => ({ ...prev, name: "" })); + }; + const openCreateModal = () => { setEditingSupplier(null); - setForm({ - name: "", - ico: "", - dic: "", - contact_person: "", - email: "", - phone: "", - address: "", - notes: "", - }); + setForm({ ...EMPTY_SUPPLIER_FORM }); + setCustomFields([]); setErrors({}); setShowModal(true); }; @@ -196,12 +246,19 @@ export default function WarehouseSuppliers() { 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 || "", + street: supplier.street || "", + city: supplier.city || "", + postal_code: supplier.postal_code || "", + country: supplier.country || "", }); + setCustomFields( + Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0 + ? supplier.custom_fields.map((f) => ({ + ...f, + _key: `cf-${++customFieldKeyCounter.current}`, + })) + : [], + ); setErrors({}); setShowModal(true); }; @@ -213,7 +270,16 @@ export default function WarehouseSuppliers() { if (Object.keys(newErrors).length > 0) return; try { - await submitMutation.mutateAsync(form); + await submitMutation.mutateAsync({ + ...form, + custom_fields: customFields + .filter((f) => f.name.trim() || f.value.trim()) + .map((f) => ({ + name: f.name, + value: f.value, + showLabel: f.showLabel, + })), + }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } @@ -281,23 +347,16 @@ export default function WarehouseSuppliers() { render: (s) => s.dic || "—", }, { - key: "contact_person", - header: "Kontaktní osoba", - width: "16%", - render: (s) => s.contact_person || "—", + key: "street", + header: "Ulice", + width: "18%", + render: (s) => s.street || "—", }, { - key: "email", - header: "E-mail", - width: "16%", - render: (s) => s.email || "—", - }, - { - key: "phone", - header: "Telefon", - width: "12%", - mono: true, - render: (s) => s.phone || "—", + key: "city", + header: "Město", + width: "14%", + render: (s) => s.city || "—", }, { key: "status", @@ -394,13 +453,15 @@ export default function WarehouseSuppliers() { /> - {/* Add/Edit Modal */} + {/* Add/Edit Modal — mirror of the customers modal (minus the PDF + field-order picker) */} setShowModal(false)} onSubmit={handleSubmit} title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"} loading={submitMutation.isPending} + maxWidth="md" > ({ ...prev, name: "" })); }} - placeholder="Název dodavatele" + placeholder="Název firmy / jméno" + InputProps={{ + endAdornment: ( + + ), + }} + /> + + + + setForm({ ...form, street: e.target.value })} + /> + + + + + setForm({ ...form, city: e.target.value })} + /> + + + + setForm({ ...form, postal_code: e.target.value }) + } + /> + + + + + setForm({ ...form, country: e.target.value })} /> @@ -425,72 +532,129 @@ export default function WarehouseSuppliers() { setForm({ ...form, ico: e.target.value })} - placeholder="12345678" + InputProps={{ + endAdornment: ( + + ), + }} /> setForm({ ...form, dic: e.target.value })} - placeholder="CZ12345678" /> - - - - setForm({ ...form, contact_person: e.target.value }) - } - placeholder="Jan Novák" - /> - - - setForm({ ...form, email: e.target.value })} - placeholder="info@firma.cz" - /> - + {/* Dynamic custom fields — same editor as the customers modal */} + + + Vlastní pole + + {customFields.map((field, idx) => ( + + + { + const updated = [...customFields]; + updated[idx] = { ...updated[idx], name: e.target.value }; + setCustomFields(updated); + }} + placeholder="Např. Kontakt" + /> + + { + const updated = [...customFields]; + updated[idx] = { + ...updated[idx], + value: e.target.value, + }; + setCustomFields(updated); + }} + sx={{ flex: 1 }} + /> + + setCustomFields(customFields.filter((_, i) => i !== idx)) + } + title="Odebrat pole" + aria-label="Odebrat pole" + > + {RemoveIcon} + + + + + + Zobrazit název v PDF + + } + checked={field.showLabel !== false} + onChange={(checked) => { + const updated = [...customFields]; + updated[idx] = { ...updated[idx], showLabel: checked }; + setCustomFields(updated); + }} + /> + + + ))} + - - - setForm({ ...form, phone: e.target.value })} - placeholder="+420 123 456 789" - /> - - - - setForm({ ...form, address: e.target.value })} - placeholder="Ulice, město, PSČ" - /> - - - - setForm({ ...form, notes: e.target.value })} - placeholder="Volitelné poznámky" - /> - {/* Deactivate/Delete Confirmation */} diff --git a/src/routes/admin/customers.ts b/src/routes/admin/customers.ts index 9678974..5225d12 100644 --- a/src/routes/admin/customers.ts +++ b/src/routes/admin/customers.ts @@ -9,48 +9,20 @@ import { CreateCustomerSchema, UpdateCustomerSchema, } from "../../schemas/customers.schema"; +import { + encodeCustomFields, + decodeCustomFields as decodeCustomFieldsShared, +} from "../../utils/custom-fields"; const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"]; -/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */ -function encodeCustomFields( - fields: unknown, - fieldOrder: unknown, -): string | null { - const f = Array.isArray(fields) ? fields : []; - const o = Array.isArray(fieldOrder) ? fieldOrder : []; - if (f.length === 0 && o.length === 0) return null; - return JSON.stringify({ fields: f, field_order: o }); -} - -/** Decode custom_fields JSON blob into separate fields + field_order for frontend */ +/** Customer-shaped wrapper over the shared codec (field_order key naming). */ function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; customer_field_order: string[]; } { - if (!raw) return { custom_fields: [], customer_field_order: [] }; - try { - const parsed = JSON.parse(raw); - // PHP format: { fields: [...], field_order: [...] } - if ( - parsed && - typeof parsed === "object" && - !Array.isArray(parsed) && - "fields" in parsed - ) { - return { - custom_fields: parsed.fields || [], - customer_field_order: parsed.field_order || [], - }; - } - // Legacy TS format: raw array - if (Array.isArray(parsed)) { - return { custom_fields: parsed, customer_field_order: [] }; - } - return { custom_fields: [], customer_field_order: [] }; - } catch { - return { custom_fields: [], customer_field_order: [] }; - } + const { custom_fields, field_order } = decodeCustomFieldsShared(raw); + return { custom_fields, customer_field_order: field_order }; } export default async function customersRoutes( diff --git a/src/routes/admin/issued-orders-pdf.ts b/src/routes/admin/issued-orders-pdf.ts index e81f7f2..1bb9a8c 100644 --- a/src/routes/admin/issued-orders-pdf.ts +++ b/src/routes/admin/issued-orders-pdf.ts @@ -117,11 +117,12 @@ function buildAddressLines( } /** - * Address block for the sklad_suppliers counterparty. Unlike customers (which - * have structured street/city/postal columns), suppliers.address is a single - * Text blob — split it on newlines into one rendered line each (a blob without - * newlines renders as one line). IČO/DIČ come from the supplier's ico/dic - * columns, prefixed with the same translated labels the customer block used. + * Address block for the sklad_suppliers counterparty — full customer model + * (structured street/city/postal_code/country + custom_fields JSON; the old + * dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from + * the supplier's ico/dic columns, prefixed with the same translated labels + * the customer block uses; custom fields render as appended lines in array + * order (suppliers have no PDF field-order picker). */ function buildSupplierLines( supplier: Record | null, @@ -130,14 +131,52 @@ function buildSupplierLines( if (!supplier) return { name: "", lines: [] }; const name = String(supplier.name || ""); const lines: string[] = []; - if (supplier.address) { - for (const part of String(supplier.address).split(/\r?\n/)) { - const line = part.trim(); - if (line) lines.push(line); - } - } + if (supplier.street) lines.push(String(supplier.street)); + const cityLine = [supplier.postal_code, supplier.city] + .filter(Boolean) + .map(String) + .join(" ") + .trim(); + if (cityLine) lines.push(cityLine); + if (supplier.country) lines.push(String(supplier.country)); if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`); if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`); + + // Custom fields ("Vlastní pole") — same blob format as customers; malformed + // JSON degrades to no extra lines rather than failing the PDF. + if (supplier.custom_fields) { + let parsed: unknown; + try { + parsed = + typeof supplier.custom_fields === "string" + ? JSON.parse(supplier.custom_fields) + : supplier.custom_fields; + } catch { + parsed = null; + } + const fields = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? ((parsed as Record).fields as Array<{ + name?: string; + value?: string; + showLabel?: boolean; + }>) + : Array.isArray(parsed) + ? (parsed as Array<{ + name?: string; + value?: string; + showLabel?: boolean; + }>) + : []; + for (const f of fields ?? []) { + const value = (f?.value || "").trim(); + if (!value) continue; + const label = (f?.name || "").trim(); + lines.push( + f?.showLabel !== false && label ? `${label}: ${value}` : value, + ); + } + } return { name, lines }; } diff --git a/src/routes/admin/issued-orders.ts b/src/routes/admin/issued-orders.ts index 0a4f112..4305596 100644 --- a/src/routes/admin/issued-orders.ts +++ b/src/routes/admin/issued-orders.ts @@ -113,9 +113,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) { name: true, ico: true, dic: true, - address: true, - email: true, - phone: true, + street: true, + city: true, + postal_code: true, + country: true, }, // id tiebreak so same-name suppliers sort deterministically. orderBy: [{ name: "asc" }, { id: "asc" }], diff --git a/src/routes/admin/warehouse.ts b/src/routes/admin/warehouse.ts index eab436b..c88dfa3 100644 --- a/src/routes/admin/warehouse.ts +++ b/src/routes/admin/warehouse.ts @@ -7,6 +7,10 @@ import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; import { contentDisposition } from "../../utils/content-disposition"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; +import { + encodeCustomFields, + decodeCustomFields, +} from "../../utils/custom-fields"; import { parseBody } from "../../schemas/common"; import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { @@ -226,7 +230,7 @@ export default async function warehouseRoutes( // SUPPLIERS // ============================================================= - // GET /suppliers — paginated list, search by name/ico/contact_person + // GET /suppliers — paginated list, search by name/ico/city fastify.get( "/suppliers", { preHandler: requirePermission("warehouse.manage") }, @@ -240,7 +244,7 @@ export default async function warehouseRoutes( where.OR = [ { name: { contains: search } }, { ico: { contains: search } }, - { contact_person: { contains: search } }, + { city: { contains: search } }, ]; } @@ -254,9 +258,16 @@ export default async function warehouseRoutes( prisma.sklad_suppliers.count({ where }), ]); + // Decode the custom_fields blob into the array shape the modal edits + // (same model as customers). + const enriched = suppliers.map((s) => ({ + ...s, + custom_fields: decodeCustomFields(s.custom_fields).custom_fields, + })); + return paginated( reply, - suppliers, + enriched, buildPaginationMeta(total, page, limit), ); }, @@ -275,7 +286,10 @@ export default async function warehouseRoutes( }); if (!supplier) return error(reply, "Dodavatel nenalezen", 404); - return success(reply, supplier); + return success(reply, { + ...supplier, + custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields, + }); }, ); @@ -293,11 +307,12 @@ export default async function warehouseRoutes( name: body.name, ico: body.ico ?? null, dic: body.dic ?? null, - contact_person: body.contact_person ?? null, - email: body.email ?? null, - phone: body.phone ?? null, - address: body.address ?? null, - notes: body.notes ?? null, + street: body.street ?? null, + city: body.city ?? null, + postal_code: body.postal_code ?? null, + country: body.country ?? null, + // Suppliers have no PDF field-order picker — order is always []. + custom_fields: encodeCustomFields(body.custom_fields, []), is_active: body.is_active ?? true, }, }); @@ -312,7 +327,7 @@ export default async function warehouseRoutes( newValues: { name: supplier.name, ico: supplier.ico, - contact_person: supplier.contact_person, + city: supplier.city, is_active: supplier.is_active, }, }); @@ -342,12 +357,13 @@ export default async function warehouseRoutes( if (body.name !== undefined) updateData.name = body.name; if (body.ico !== undefined) updateData.ico = body.ico; if (body.dic !== undefined) updateData.dic = body.dic; - if (body.contact_person !== undefined) - updateData.contact_person = body.contact_person; - if (body.email !== undefined) updateData.email = body.email; - if (body.phone !== undefined) updateData.phone = body.phone; - if (body.address !== undefined) updateData.address = body.address; - if (body.notes !== undefined) updateData.notes = body.notes; + if (body.street !== undefined) updateData.street = body.street; + if (body.city !== undefined) updateData.city = body.city; + if (body.postal_code !== undefined) + updateData.postal_code = body.postal_code; + if (body.country !== undefined) updateData.country = body.country; + if (body.custom_fields !== undefined) + updateData.custom_fields = encodeCustomFields(body.custom_fields, []); if (body.is_active !== undefined) updateData.is_active = body.is_active; const updated = await prisma.sklad_suppliers.update({ @@ -365,13 +381,13 @@ export default async function warehouseRoutes( oldValues: { name: existing.name, ico: existing.ico, - contact_person: existing.contact_person, + city: existing.city, is_active: existing.is_active, }, newValues: { name: updated.name, ico: updated.ico, - contact_person: updated.contact_person, + city: updated.city, is_active: updated.is_active, }, }); diff --git a/src/schemas/warehouse.schema.ts b/src/schemas/warehouse.schema.ts index 5c8cab4..a35004b 100644 --- a/src/schemas/warehouse.schema.ts +++ b/src/schemas/warehouse.schema.ts @@ -8,7 +8,6 @@ import { nullableIntIdFromForm, booleanFromForm, isoDateString, - emailOrEmpty, } from "./common"; // === Categories === @@ -26,26 +25,30 @@ export type CreateCategoryInput = z.infer; export type UpdateCategoryInput = z.infer; // === Suppliers === +// Full mirror of the customers model: structured address + custom_fields +// (the dedicated contact_person/email/phone/notes columns and the free-text +// `address` blob were dropped; legacy clients sending them are silently +// stripped by z.object). Limits are DB-aligned. export const CreateSupplierSchema = z.object({ name: z.string().min(1, "Název je povinný").max(255), ico: z.string().max(255).nullish(), dic: z.string().max(255).nullish(), - contact_person: z.string().max(255).nullish(), - email: emailOrEmpty.nullish(), - phone: z.string().max(50).nullish(), - address: z.string().max(5000).nullish(), - notes: z.string().max(5000).nullish(), + street: z.string().max(255).nullish(), + city: z.string().max(255).nullish(), + postal_code: z.string().max(20).nullish(), + country: z.string().max(100).nullish(), + custom_fields: z.array(z.unknown()).max(100).optional(), is_active: booleanFromForm.optional().default(true), }); export const UpdateSupplierSchema = z.object({ name: z.string().min(1, "Název je povinný").max(255).optional(), ico: z.string().max(255).nullish(), dic: z.string().max(255).nullish(), - contact_person: z.string().max(255).nullish(), - email: emailOrEmpty.nullish(), - phone: z.string().max(50).nullish(), - address: z.string().max(5000).nullish(), - notes: z.string().max(5000).nullish(), + street: z.string().max(255).nullish(), + city: z.string().max(255).nullish(), + postal_code: z.string().max(20).nullish(), + country: z.string().max(100).nullish(), + custom_fields: z.array(z.unknown()).max(100).optional(), is_active: booleanFromForm.optional(), }); export type CreateSupplierInput = z.infer; diff --git a/src/services/issued-orders.service.ts b/src/services/issued-orders.service.ts index 447214d..2247430 100644 --- a/src/services/issued-orders.service.ts +++ b/src/services/issued-orders.service.ts @@ -231,9 +231,10 @@ export async function getIssuedOrder(id: number) { name: true, ico: true, dic: true, - address: true, - email: true, - phone: true, + street: true, + city: true, + postal_code: true, + country: true, }, }, issued_order_items: { orderBy: { position: "asc" } }, diff --git a/src/utils/custom-fields.ts b/src/utils/custom-fields.ts new file mode 100644 index 0000000..3c27e5a --- /dev/null +++ b/src/utils/custom-fields.ts @@ -0,0 +1,48 @@ +/** + * Shared encode/decode for the custom_fields JSON blob carried by customers + * AND warehouse suppliers (one column, PHP-compatible format: + * `{ "fields": [{name, value, showLabel}], "field_order": [...] }`). + * Lifted from routes/admin/customers.ts when suppliers gained the same + * "Vlastní pole" model (2026-06). + */ + +/** Encode custom_fields + field_order into a single JSON blob (matching PHP format). */ +export function encodeCustomFields( + fields: unknown, + fieldOrder: unknown, +): string | null { + const f = Array.isArray(fields) ? fields : []; + const o = Array.isArray(fieldOrder) ? fieldOrder : []; + if (f.length === 0 && o.length === 0) return null; + return JSON.stringify({ fields: f, field_order: o }); +} + +/** Decode the custom_fields JSON blob into separate fields + field_order for the frontend. */ +export function decodeCustomFields(raw: string | null): { + custom_fields: unknown[]; + field_order: string[]; +} { + if (!raw) return { custom_fields: [], field_order: [] }; + try { + const parsed = JSON.parse(raw); + // PHP format: { fields: [...], field_order: [...] } + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + "fields" in parsed + ) { + return { + custom_fields: parsed.fields || [], + field_order: parsed.field_order || [], + }; + } + // Legacy TS format: raw array + if (Array.isArray(parsed)) { + return { custom_fields: parsed, field_order: [] }; + } + return { custom_fields: [], field_order: [] }; + } catch { + return { custom_fields: [], field_order: [] }; + } +}