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: [] };
+ }
+}