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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`;
|
||||||
@@ -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`;
|
||||||
@@ -804,18 +804,18 @@ model sklad_categories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model sklad_suppliers {
|
model sklad_suppliers {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
ico String? @db.VarChar(20)
|
ico String? @db.VarChar(20)
|
||||||
dic String? @db.VarChar(20)
|
dic String? @db.VarChar(20)
|
||||||
contact_person String? @db.VarChar(255)
|
street String? @db.VarChar(255)
|
||||||
email String? @db.VarChar(255)
|
city String? @db.VarChar(255)
|
||||||
phone String? @db.VarChar(50)
|
postal_code String? @db.VarChar(20)
|
||||||
address String? @db.Text
|
country String? @db.VarChar(100)
|
||||||
notes String? @db.Text
|
custom_fields String? @db.LongText
|
||||||
is_active Boolean @default(true)
|
is_active Boolean @default(true)
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
receipts sklad_receipts[]
|
receipts sklad_receipts[]
|
||||||
issued_orders issued_orders[]
|
issued_orders issued_orders[]
|
||||||
|
|||||||
@@ -450,9 +450,20 @@ describe("GET /api/admin/issued-orders/suppliers", () => {
|
|||||||
const row = body.data.find((s: { id: number }) => s.id === active.id);
|
const row = body.data.find((s: { id: number }) => s.id === active.id);
|
||||||
expect(row.name).toBe("io_test_Aktivní dodavatel");
|
expect(row.name).toBe("io_test_Aktivní dodavatel");
|
||||||
expect(row.ico).toBe("12345678");
|
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(
|
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" };
|
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 = {
|
const supplier = {
|
||||||
name: "Dodavatel s.r.o.",
|
name: "Dodavatel s.r.o.",
|
||||||
ico: "12345678",
|
ico: "12345678",
|
||||||
dic: "CZ12345678",
|
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)", () => {
|
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||||
@@ -685,7 +699,7 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).toContain("Odběratel");
|
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(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
@@ -694,25 +708,26 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
"cs",
|
"cs",
|
||||||
issuer,
|
issuer,
|
||||||
);
|
);
|
||||||
// The single Text address blob becomes one address line per newline.
|
// street / "PSČ Město" / country — one address line each.
|
||||||
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
|
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
|
||||||
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
|
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
|
||||||
|
expect(html).toContain('<div class="address-line">Česká republika</div>');
|
||||||
expect(html).toContain("IČ: 12345678");
|
expect(html).toContain("IČ: 12345678");
|
||||||
expect(html).toContain("DIČ: CZ12345678");
|
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(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
|
{ name: "Jednořádkový", street: "Ulice 1", city: "Praha" },
|
||||||
null,
|
null,
|
||||||
"cs",
|
"cs",
|
||||||
issuer,
|
issuer,
|
||||||
);
|
);
|
||||||
expect(html).toContain(
|
expect(html).toContain('<div class="address-line">Ulice 1</div>');
|
||||||
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
|
// City without PSČ renders alone, no stray separator.
|
||||||
);
|
expect(html).toContain('<div class="address-line">Praha</div>');
|
||||||
expect(html).not.toContain("IČ: ");
|
expect(html).not.toContain("IČ: ");
|
||||||
expect(html).not.toContain("DIČ: ");
|
expect(html).not.toContain("DIČ: ");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -240,18 +240,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
|
it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
|
||||||
expect(
|
// email/phone/contact_person/notes/address columns were replaced by the
|
||||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
|
// customers-model custom_fields blob (2026-06); legacy clients still
|
||||||
).toBe(true);
|
// sending them must not 400 — z.object strips unknown keys.
|
||||||
expect(
|
const parsed = CreateSupplierSchema.safeParse({
|
||||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
|
name: "Dodavatel",
|
||||||
.success,
|
email: "x@y.cz",
|
||||||
).toBe(true);
|
phone: "123",
|
||||||
expect(
|
contact_person: "Jan",
|
||||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
|
notes: "n",
|
||||||
.success,
|
address: "Ulice 1",
|
||||||
).toBe(false);
|
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", () => {
|
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ export interface Supplier {
|
|||||||
name: string;
|
name: string;
|
||||||
ico: string | null;
|
ico: string | null;
|
||||||
dic: string | null;
|
dic: string | null;
|
||||||
address: string | null;
|
street: string | null;
|
||||||
email: string | null;
|
city: string | null;
|
||||||
phone: string | null;
|
postal_code: string | null;
|
||||||
|
country: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssuedOrderItem {
|
export interface IssuedOrderItem {
|
||||||
|
|||||||
@@ -157,16 +157,23 @@ export interface WarehouseLocation {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WarehouseSupplierCustomField {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
showLabel: boolean;
|
||||||
|
_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WarehouseSupplier {
|
export interface WarehouseSupplier {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ico: string | null;
|
ico: string | null;
|
||||||
dic: string | null;
|
dic: string | null;
|
||||||
contact_person: string | null;
|
street: string | null;
|
||||||
email: string | null;
|
city: string | null;
|
||||||
phone: string | null;
|
postal_code: string | null;
|
||||||
address: string | null;
|
country: string | null;
|
||||||
notes: string | null;
|
custom_fields?: WarehouseSupplierCustomField[];
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,9 +207,10 @@ export default function IssuedOrderDetail() {
|
|||||||
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
|
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
|
||||||
ico: null,
|
ico: null,
|
||||||
dic: null,
|
dic: null,
|
||||||
address: null,
|
street: null,
|
||||||
email: null,
|
city: null,
|
||||||
phone: null,
|
postal_code: null,
|
||||||
|
country: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import {
|
import {
|
||||||
warehouseSupplierListOptions,
|
warehouseSupplierListOptions,
|
||||||
type WarehouseSupplier,
|
type WarehouseSupplier,
|
||||||
|
type WarehouseSupplierCustomField,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Field,
|
Field,
|
||||||
TextField,
|
TextField,
|
||||||
|
CheckboxField,
|
||||||
StatusChip,
|
StatusChip,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
@@ -32,17 +35,29 @@ import {
|
|||||||
|
|
||||||
const API_BASE = "/api/admin/warehouse/suppliers";
|
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 {
|
interface SupplierForm {
|
||||||
name: string;
|
name: string;
|
||||||
ico: string;
|
ico: string;
|
||||||
dic: string;
|
dic: string;
|
||||||
contact_person: string;
|
street: string;
|
||||||
email: string;
|
city: string;
|
||||||
phone: string;
|
postal_code: string;
|
||||||
address: string;
|
country: string;
|
||||||
notes: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_SUPPLIER_FORM: SupplierForm = {
|
||||||
|
name: "",
|
||||||
|
ico: "",
|
||||||
|
dic: "",
|
||||||
|
street: "",
|
||||||
|
city: "",
|
||||||
|
postal_code: "",
|
||||||
|
country: "",
|
||||||
|
};
|
||||||
|
|
||||||
const PER_PAGE = 20;
|
const PER_PAGE = 20;
|
||||||
|
|
||||||
const PlusIcon = (
|
const PlusIcon = (
|
||||||
@@ -88,6 +103,32 @@ const DeleteIcon = (
|
|||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
const SmallPlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const RemoveIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WarehouseSuppliers() {
|
export default function WarehouseSuppliers() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -113,16 +154,11 @@ export default function WarehouseSuppliers() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingSupplier, setEditingSupplier] =
|
const [editingSupplier, setEditingSupplier] =
|
||||||
useState<WarehouseSupplier | null>(null);
|
useState<WarehouseSupplier | null>(null);
|
||||||
const [form, setForm] = useState<SupplierForm>({
|
const [form, setForm] = useState<SupplierForm>({ ...EMPTY_SUPPLIER_FORM });
|
||||||
name: "",
|
const [customFields, setCustomFields] = useState<
|
||||||
ico: "",
|
WarehouseSupplierCustomField[]
|
||||||
dic: "",
|
>([]);
|
||||||
contact_person: "",
|
const customFieldKeyCounter = useRef(0);
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
address: "",
|
|
||||||
notes: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [deactivateConfirm, setDeactivateConfirm] = useState<{
|
const [deactivateConfirm, setDeactivateConfirm] = useState<{
|
||||||
@@ -131,7 +167,13 @@ export default function WarehouseSuppliers() {
|
|||||||
}>({ show: false, supplier: null });
|
}>({ show: false, supplier: null });
|
||||||
|
|
||||||
const submitMutation = useApiMutation<
|
const submitMutation = useApiMutation<
|
||||||
SupplierForm,
|
SupplierForm & {
|
||||||
|
custom_fields: Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
showLabel?: boolean;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
{ id?: number; message?: string }
|
{ id?: number; message?: string }
|
||||||
>({
|
>({
|
||||||
url: () =>
|
url: () =>
|
||||||
@@ -174,18 +216,26 @@ export default function WarehouseSuppliers() {
|
|||||||
|
|
||||||
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
||||||
|
|
||||||
|
// 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 = () => {
|
const openCreateModal = () => {
|
||||||
setEditingSupplier(null);
|
setEditingSupplier(null);
|
||||||
setForm({
|
setForm({ ...EMPTY_SUPPLIER_FORM });
|
||||||
name: "",
|
setCustomFields([]);
|
||||||
ico: "",
|
|
||||||
dic: "",
|
|
||||||
contact_person: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
address: "",
|
|
||||||
notes: "",
|
|
||||||
});
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@@ -196,12 +246,19 @@ export default function WarehouseSuppliers() {
|
|||||||
name: supplier.name,
|
name: supplier.name,
|
||||||
ico: supplier.ico || "",
|
ico: supplier.ico || "",
|
||||||
dic: supplier.dic || "",
|
dic: supplier.dic || "",
|
||||||
contact_person: supplier.contact_person || "",
|
street: supplier.street || "",
|
||||||
email: supplier.email || "",
|
city: supplier.city || "",
|
||||||
phone: supplier.phone || "",
|
postal_code: supplier.postal_code || "",
|
||||||
address: supplier.address || "",
|
country: supplier.country || "",
|
||||||
notes: supplier.notes || "",
|
|
||||||
});
|
});
|
||||||
|
setCustomFields(
|
||||||
|
Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0
|
||||||
|
? supplier.custom_fields.map((f) => ({
|
||||||
|
...f,
|
||||||
|
_key: `cf-${++customFieldKeyCounter.current}`,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@@ -213,7 +270,16 @@ export default function WarehouseSuppliers() {
|
|||||||
if (Object.keys(newErrors).length > 0) return;
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
@@ -281,23 +347,16 @@ export default function WarehouseSuppliers() {
|
|||||||
render: (s) => s.dic || "—",
|
render: (s) => s.dic || "—",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "contact_person",
|
key: "street",
|
||||||
header: "Kontaktní osoba",
|
header: "Ulice",
|
||||||
width: "16%",
|
width: "18%",
|
||||||
render: (s) => s.contact_person || "—",
|
render: (s) => s.street || "—",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "email",
|
key: "city",
|
||||||
header: "E-mail",
|
header: "Město",
|
||||||
width: "16%",
|
width: "14%",
|
||||||
render: (s) => s.email || "—",
|
render: (s) => s.city || "—",
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "phone",
|
|
||||||
header: "Telefon",
|
|
||||||
width: "12%",
|
|
||||||
mono: true,
|
|
||||||
render: (s) => s.phone || "—",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
@@ -394,13 +453,15 @@ export default function WarehouseSuppliers() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
{/* Add/Edit Modal — mirror of the customers modal (minus the PDF
|
||||||
|
field-order picker) */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
||||||
loading={submitMutation.isPending}
|
loading={submitMutation.isPending}
|
||||||
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<Field label="Název" required error={errors.name}>
|
<Field label="Název" required error={errors.name}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -410,7 +471,53 @@ export default function WarehouseSuppliers() {
|
|||||||
setForm({ ...form, name: e.target.value });
|
setForm({ ...form, name: e.target.value });
|
||||||
setErrors((prev) => ({ ...prev, name: "" }));
|
setErrors((prev) => ({ ...prev, name: "" }));
|
||||||
}}
|
}}
|
||||||
placeholder="Název dodavatele"
|
placeholder="Název firmy / jméno"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<AresAdornment
|
||||||
|
mode="name"
|
||||||
|
query={form.name}
|
||||||
|
onFill={fillFromAres}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Ulice">
|
||||||
|
<TextField
|
||||||
|
value={form.street}
|
||||||
|
onChange={(e) => setForm({ ...form, street: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Město">
|
||||||
|
<TextField
|
||||||
|
value={form.city}
|
||||||
|
onChange={(e) => setForm({ ...form, city: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="PSČ">
|
||||||
|
<TextField
|
||||||
|
value={form.postal_code}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, postal_code: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Země">
|
||||||
|
<TextField
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) => setForm({ ...form, country: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -425,72 +532,129 @@ export default function WarehouseSuppliers() {
|
|||||||
<TextField
|
<TextField
|
||||||
value={form.ico}
|
value={form.ico}
|
||||||
onChange={(e) => setForm({ ...form, ico: e.target.value })}
|
onChange={(e) => setForm({ ...form, ico: e.target.value })}
|
||||||
placeholder="12345678"
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<AresAdornment
|
||||||
|
mode="ico"
|
||||||
|
query={form.ico}
|
||||||
|
onFill={fillFromAres}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="DIČ">
|
<Field label="DIČ">
|
||||||
<TextField
|
<TextField
|
||||||
value={form.dic}
|
value={form.dic}
|
||||||
onChange={(e) => setForm({ ...form, dic: e.target.value })}
|
onChange={(e) => setForm({ ...form, dic: e.target.value })}
|
||||||
placeholder="CZ12345678"
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
{/* Dynamic custom fields — same editor as the customers modal */}
|
||||||
sx={{
|
<Box sx={{ mt: 0.5 }}>
|
||||||
display: "grid",
|
<Typography
|
||||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
variant="body2"
|
||||||
gap: 2,
|
sx={{
|
||||||
}}
|
display: "block",
|
||||||
>
|
mb: 0.5,
|
||||||
<Field label="Kontaktní osoba">
|
fontWeight: 600,
|
||||||
<TextField
|
color: "text.secondary",
|
||||||
value={form.contact_person}
|
}}
|
||||||
onChange={(e) =>
|
>
|
||||||
setForm({ ...form, contact_person: e.target.value })
|
Vlastní pole
|
||||||
}
|
</Typography>
|
||||||
placeholder="Jan Novák"
|
{customFields.map((field, idx) => (
|
||||||
/>
|
<Box key={field._key} sx={{ mb: 1 }}>
|
||||||
</Field>
|
<Box
|
||||||
<Field label="E-mail">
|
sx={{
|
||||||
<TextField
|
display: "grid",
|
||||||
type="email"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
value={form.email}
|
gap: 2,
|
||||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
alignItems: "flex-start",
|
||||||
placeholder="info@firma.cz"
|
}}
|
||||||
/>
|
>
|
||||||
</Field>
|
<TextField
|
||||||
|
label={idx === 0 ? "Název" : undefined}
|
||||||
|
value={field.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...customFields];
|
||||||
|
updated[idx] = { ...updated[idx], name: e.target.value };
|
||||||
|
setCustomFields(updated);
|
||||||
|
}}
|
||||||
|
placeholder="Např. Kontakt"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 0.5,
|
||||||
|
alignItems: idx === 0 ? "flex-end" : "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label={idx === 0 ? "Hodnota" : undefined}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...customFields];
|
||||||
|
updated[idx] = {
|
||||||
|
...updated[idx],
|
||||||
|
value: e.target.value,
|
||||||
|
};
|
||||||
|
setCustomFields(updated);
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() =>
|
||||||
|
setCustomFields(customFields.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
title="Odebrat pole"
|
||||||
|
aria-label="Odebrat pole"
|
||||||
|
>
|
||||||
|
{RemoveIcon}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<CheckboxField
|
||||||
|
label={
|
||||||
|
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||||
|
Zobrazit název v PDF
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
checked={field.showLabel !== false}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const updated = [...customFields];
|
||||||
|
updated[idx] = { ...updated[idx], showLabel: checked };
|
||||||
|
setCustomFields(updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
startIcon={SmallPlusIcon}
|
||||||
|
onClick={() =>
|
||||||
|
setCustomFields([
|
||||||
|
...customFields,
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
showLabel: true,
|
||||||
|
_key: `cf-${++customFieldKeyCounter.current}`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
>
|
||||||
|
Přidat pole
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Field label="Telefon">
|
|
||||||
<TextField
|
|
||||||
type="tel"
|
|
||||||
value={form.phone}
|
|
||||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
|
||||||
placeholder="+420 123 456 789"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Adresa">
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
value={form.address}
|
|
||||||
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
|
||||||
placeholder="Ulice, město, PSČ"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Poznámky">
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
|
||||||
placeholder="Volitelné poznámky"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Deactivate/Delete Confirmation */}
|
{/* Deactivate/Delete Confirmation */}
|
||||||
|
|||||||
@@ -9,48 +9,20 @@ import {
|
|||||||
CreateCustomerSchema,
|
CreateCustomerSchema,
|
||||||
UpdateCustomerSchema,
|
UpdateCustomerSchema,
|
||||||
} from "../../schemas/customers.schema";
|
} from "../../schemas/customers.schema";
|
||||||
|
import {
|
||||||
|
encodeCustomFields,
|
||||||
|
decodeCustomFields as decodeCustomFieldsShared,
|
||||||
|
} from "../../utils/custom-fields";
|
||||||
|
|
||||||
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
|
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
|
||||||
|
|
||||||
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
|
/** Customer-shaped wrapper over the shared codec (field_order key naming). */
|
||||||
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 */
|
|
||||||
function decodeCustomFields(raw: string | null): {
|
function decodeCustomFields(raw: string | null): {
|
||||||
custom_fields: unknown[];
|
custom_fields: unknown[];
|
||||||
customer_field_order: string[];
|
customer_field_order: string[];
|
||||||
} {
|
} {
|
||||||
if (!raw) return { custom_fields: [], customer_field_order: [] };
|
const { custom_fields, field_order } = decodeCustomFieldsShared(raw);
|
||||||
try {
|
return { custom_fields, customer_field_order: field_order };
|
||||||
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: [] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function customersRoutes(
|
export default async function customersRoutes(
|
||||||
|
|||||||
@@ -117,11 +117,12 @@ function buildAddressLines(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Address block for the sklad_suppliers counterparty. Unlike customers (which
|
* Address block for the sklad_suppliers counterparty — full customer model
|
||||||
* have structured street/city/postal columns), suppliers.address is a single
|
* (structured street/city/postal_code/country + custom_fields JSON; the old
|
||||||
* Text blob — split it on newlines into one rendered line each (a blob without
|
* dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from
|
||||||
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
|
* the supplier's ico/dic columns, prefixed with the same translated labels
|
||||||
* columns, prefixed with the same translated labels the customer block used.
|
* the customer block uses; custom fields render as appended lines in array
|
||||||
|
* order (suppliers have no PDF field-order picker).
|
||||||
*/
|
*/
|
||||||
function buildSupplierLines(
|
function buildSupplierLines(
|
||||||
supplier: Record<string, unknown> | null,
|
supplier: Record<string, unknown> | null,
|
||||||
@@ -130,14 +131,52 @@ function buildSupplierLines(
|
|||||||
if (!supplier) return { name: "", lines: [] };
|
if (!supplier) return { name: "", lines: [] };
|
||||||
const name = String(supplier.name || "");
|
const name = String(supplier.name || "");
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (supplier.address) {
|
if (supplier.street) lines.push(String(supplier.street));
|
||||||
for (const part of String(supplier.address).split(/\r?\n/)) {
|
const cityLine = [supplier.postal_code, supplier.city]
|
||||||
const line = part.trim();
|
.filter(Boolean)
|
||||||
if (line) lines.push(line);
|
.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.ico) lines.push(`${tObj.ico}${supplier.ico}`);
|
||||||
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
|
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<string, unknown>).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 };
|
return { name, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
name: true,
|
name: true,
|
||||||
ico: true,
|
ico: true,
|
||||||
dic: true,
|
dic: true,
|
||||||
address: true,
|
street: true,
|
||||||
email: true,
|
city: true,
|
||||||
phone: true,
|
postal_code: true,
|
||||||
|
country: true,
|
||||||
},
|
},
|
||||||
// id tiebreak so same-name suppliers sort deterministically.
|
// id tiebreak so same-name suppliers sort deterministically.
|
||||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { logAudit } from "../../services/audit";
|
|||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { contentDisposition } from "../../utils/content-disposition";
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
|
import {
|
||||||
|
encodeCustomFields,
|
||||||
|
decodeCustomFields,
|
||||||
|
} from "../../utils/custom-fields";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||||
import {
|
import {
|
||||||
@@ -226,7 +230,7 @@ export default async function warehouseRoutes(
|
|||||||
// SUPPLIERS
|
// SUPPLIERS
|
||||||
// =============================================================
|
// =============================================================
|
||||||
|
|
||||||
// GET /suppliers — paginated list, search by name/ico/contact_person
|
// GET /suppliers — paginated list, search by name/ico/city
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/suppliers",
|
"/suppliers",
|
||||||
{ preHandler: requirePermission("warehouse.manage") },
|
{ preHandler: requirePermission("warehouse.manage") },
|
||||||
@@ -240,7 +244,7 @@ export default async function warehouseRoutes(
|
|||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search } },
|
{ name: { contains: search } },
|
||||||
{ ico: { 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 }),
|
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(
|
return paginated(
|
||||||
reply,
|
reply,
|
||||||
suppliers,
|
enriched,
|
||||||
buildPaginationMeta(total, page, limit),
|
buildPaginationMeta(total, page, limit),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -275,7 +286,10 @@ export default async function warehouseRoutes(
|
|||||||
});
|
});
|
||||||
if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
|
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,
|
name: body.name,
|
||||||
ico: body.ico ?? null,
|
ico: body.ico ?? null,
|
||||||
dic: body.dic ?? null,
|
dic: body.dic ?? null,
|
||||||
contact_person: body.contact_person ?? null,
|
street: body.street ?? null,
|
||||||
email: body.email ?? null,
|
city: body.city ?? null,
|
||||||
phone: body.phone ?? null,
|
postal_code: body.postal_code ?? null,
|
||||||
address: body.address ?? null,
|
country: body.country ?? null,
|
||||||
notes: body.notes ?? null,
|
// Suppliers have no PDF field-order picker — order is always [].
|
||||||
|
custom_fields: encodeCustomFields(body.custom_fields, []),
|
||||||
is_active: body.is_active ?? true,
|
is_active: body.is_active ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -312,7 +327,7 @@ export default async function warehouseRoutes(
|
|||||||
newValues: {
|
newValues: {
|
||||||
name: supplier.name,
|
name: supplier.name,
|
||||||
ico: supplier.ico,
|
ico: supplier.ico,
|
||||||
contact_person: supplier.contact_person,
|
city: supplier.city,
|
||||||
is_active: supplier.is_active,
|
is_active: supplier.is_active,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -342,12 +357,13 @@ export default async function warehouseRoutes(
|
|||||||
if (body.name !== undefined) updateData.name = body.name;
|
if (body.name !== undefined) updateData.name = body.name;
|
||||||
if (body.ico !== undefined) updateData.ico = body.ico;
|
if (body.ico !== undefined) updateData.ico = body.ico;
|
||||||
if (body.dic !== undefined) updateData.dic = body.dic;
|
if (body.dic !== undefined) updateData.dic = body.dic;
|
||||||
if (body.contact_person !== undefined)
|
if (body.street !== undefined) updateData.street = body.street;
|
||||||
updateData.contact_person = body.contact_person;
|
if (body.city !== undefined) updateData.city = body.city;
|
||||||
if (body.email !== undefined) updateData.email = body.email;
|
if (body.postal_code !== undefined)
|
||||||
if (body.phone !== undefined) updateData.phone = body.phone;
|
updateData.postal_code = body.postal_code;
|
||||||
if (body.address !== undefined) updateData.address = body.address;
|
if (body.country !== undefined) updateData.country = body.country;
|
||||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
if (body.custom_fields !== undefined)
|
||||||
|
updateData.custom_fields = encodeCustomFields(body.custom_fields, []);
|
||||||
if (body.is_active !== undefined) updateData.is_active = body.is_active;
|
if (body.is_active !== undefined) updateData.is_active = body.is_active;
|
||||||
|
|
||||||
const updated = await prisma.sklad_suppliers.update({
|
const updated = await prisma.sklad_suppliers.update({
|
||||||
@@ -365,13 +381,13 @@ export default async function warehouseRoutes(
|
|||||||
oldValues: {
|
oldValues: {
|
||||||
name: existing.name,
|
name: existing.name,
|
||||||
ico: existing.ico,
|
ico: existing.ico,
|
||||||
contact_person: existing.contact_person,
|
city: existing.city,
|
||||||
is_active: existing.is_active,
|
is_active: existing.is_active,
|
||||||
},
|
},
|
||||||
newValues: {
|
newValues: {
|
||||||
name: updated.name,
|
name: updated.name,
|
||||||
ico: updated.ico,
|
ico: updated.ico,
|
||||||
contact_person: updated.contact_person,
|
city: updated.city,
|
||||||
is_active: updated.is_active,
|
is_active: updated.is_active,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
booleanFromForm,
|
booleanFromForm,
|
||||||
isoDateString,
|
isoDateString,
|
||||||
emailOrEmpty,
|
|
||||||
} from "./common";
|
} from "./common";
|
||||||
|
|
||||||
// === Categories ===
|
// === Categories ===
|
||||||
@@ -26,26 +25,30 @@ export type CreateCategoryInput = z.infer<typeof CreateCategorySchema>;
|
|||||||
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
|
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
|
||||||
|
|
||||||
// === Suppliers ===
|
// === 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({
|
export const CreateSupplierSchema = z.object({
|
||||||
name: z.string().min(1, "Název je povinný").max(255),
|
name: z.string().min(1, "Název je povinný").max(255),
|
||||||
ico: z.string().max(255).nullish(),
|
ico: z.string().max(255).nullish(),
|
||||||
dic: z.string().max(255).nullish(),
|
dic: z.string().max(255).nullish(),
|
||||||
contact_person: z.string().max(255).nullish(),
|
street: z.string().max(255).nullish(),
|
||||||
email: emailOrEmpty.nullish(),
|
city: z.string().max(255).nullish(),
|
||||||
phone: z.string().max(50).nullish(),
|
postal_code: z.string().max(20).nullish(),
|
||||||
address: z.string().max(5000).nullish(),
|
country: z.string().max(100).nullish(),
|
||||||
notes: z.string().max(5000).nullish(),
|
custom_fields: z.array(z.unknown()).max(100).optional(),
|
||||||
is_active: booleanFromForm.optional().default(true),
|
is_active: booleanFromForm.optional().default(true),
|
||||||
});
|
});
|
||||||
export const UpdateSupplierSchema = z.object({
|
export const UpdateSupplierSchema = z.object({
|
||||||
name: z.string().min(1, "Název je povinný").max(255).optional(),
|
name: z.string().min(1, "Název je povinný").max(255).optional(),
|
||||||
ico: z.string().max(255).nullish(),
|
ico: z.string().max(255).nullish(),
|
||||||
dic: z.string().max(255).nullish(),
|
dic: z.string().max(255).nullish(),
|
||||||
contact_person: z.string().max(255).nullish(),
|
street: z.string().max(255).nullish(),
|
||||||
email: emailOrEmpty.nullish(),
|
city: z.string().max(255).nullish(),
|
||||||
phone: z.string().max(50).nullish(),
|
postal_code: z.string().max(20).nullish(),
|
||||||
address: z.string().max(5000).nullish(),
|
country: z.string().max(100).nullish(),
|
||||||
notes: z.string().max(5000).nullish(),
|
custom_fields: z.array(z.unknown()).max(100).optional(),
|
||||||
is_active: booleanFromForm.optional(),
|
is_active: booleanFromForm.optional(),
|
||||||
});
|
});
|
||||||
export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>;
|
export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>;
|
||||||
|
|||||||
@@ -231,9 +231,10 @@ export async function getIssuedOrder(id: number) {
|
|||||||
name: true,
|
name: true,
|
||||||
ico: true,
|
ico: true,
|
||||||
dic: true,
|
dic: true,
|
||||||
address: true,
|
street: true,
|
||||||
email: true,
|
city: true,
|
||||||
phone: true,
|
postal_code: true,
|
||||||
|
country: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
issued_order_items: { orderBy: { position: "asc" } },
|
issued_order_items: { orderBy: { position: "asc" } },
|
||||||
|
|||||||
48
src/utils/custom-fields.ts
Normal file
48
src/utils/custom-fields.ts
Normal file
@@ -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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user