Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dbacc3bec | ||
|
|
c21f02e5d1 | ||
|
|
f87e110359 | ||
|
|
f1b1329c1b | ||
|
|
88d5d43448 | ||
|
|
4745af3639 | ||
|
|
4258699b73 | ||
|
|
bfd2c59ad3 | ||
|
|
0b69dbfde0 | ||
|
|
db7a5c3d15 | ||
|
|
f1ce76d21d |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.5",
|
"version": "2.4.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.5",
|
"version": "2.4.10",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.5",
|
"version": "2.4.10",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
116
src/__tests__/ares.test.ts
Normal file
116
src/__tests__/ares.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { aresLookupByIco, aresSearchByName } from "../services/ares.service";
|
||||||
|
|
||||||
|
// ARES is a true external — mock global fetch (the only allowed mock class).
|
||||||
|
const SUBJECT = {
|
||||||
|
ico: "22599851",
|
||||||
|
obchodniJmeno: "BOHA Automation s.r.o.",
|
||||||
|
dic: "CZ22599851",
|
||||||
|
sidlo: {
|
||||||
|
nazevStatu: "Česká republika",
|
||||||
|
nazevObce: "Turnov",
|
||||||
|
nazevCastiObce: "Turnov",
|
||||||
|
nazevUlice: "Nádražní",
|
||||||
|
cisloDomovni: 485,
|
||||||
|
psc: 51101,
|
||||||
|
textovaAdresa: "Nádražní 485, 51101 Turnov",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetchOnce(status: number, body?: unknown) {
|
||||||
|
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aresLookupByIco", () => {
|
||||||
|
it("maps a subject to the normalized prefill shape", async () => {
|
||||||
|
mockFetchOnce(200, SUBJECT);
|
||||||
|
const res = await aresLookupByIco("22599851");
|
||||||
|
expect(res).toEqual({
|
||||||
|
ico: "22599851",
|
||||||
|
dic: "CZ22599851",
|
||||||
|
name: "BOHA Automation s.r.o.",
|
||||||
|
street: "Nádražní 485",
|
||||||
|
city: "Turnov",
|
||||||
|
postal_code: "511 01",
|
||||||
|
country: "Česká republika",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds Prague-style orientation numbers as 'Ulice 485/12a'", async () => {
|
||||||
|
mockFetchOnce(200, {
|
||||||
|
...SUBJECT,
|
||||||
|
sidlo: {
|
||||||
|
...SUBJECT.sidlo,
|
||||||
|
cisloOrientacni: 12,
|
||||||
|
cisloOrientacniPismeno: "a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await aresLookupByIco("22599851");
|
||||||
|
expect("street" in res && res.street).toBe("Nádražní 485/12a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-8-digit IČO without calling ARES", async () => {
|
||||||
|
const spy = vi.spyOn(globalThis, "fetch");
|
||||||
|
expect(await aresLookupByIco("123")).toEqual({ error: "invalid_ico" });
|
||||||
|
expect(await aresLookupByIco("abcdefgh")).toEqual({
|
||||||
|
error: "invalid_ico",
|
||||||
|
});
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an IČO with stray whitespace", async () => {
|
||||||
|
const spy = mockFetchOnce(200, SUBJECT);
|
||||||
|
const res = await aresLookupByIco(" 225 99851 ");
|
||||||
|
expect("ico" in res && res.ico).toBe("22599851");
|
||||||
|
expect(String(spy.mock.calls[0][0])).toContain("/22599851");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 404 to not_found and network failure to ares_unavailable", async () => {
|
||||||
|
mockFetchOnce(404);
|
||||||
|
expect(await aresLookupByIco("12345678")).toEqual({ error: "not_found" });
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("ETIMEDOUT"));
|
||||||
|
expect(await aresLookupByIco("12345678")).toEqual({
|
||||||
|
error: "ares_unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aresSearchByName", () => {
|
||||||
|
it("maps the result list and sends the name in the POST body", async () => {
|
||||||
|
const spy = mockFetchOnce(200, { ekonomickeSubjekty: [SUBJECT] });
|
||||||
|
const res = await aresSearchByName("BOHA Automation");
|
||||||
|
expect(Array.isArray(res)).toBe(true);
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].name).toBe("BOHA Automation s.r.o.");
|
||||||
|
expect(res[0].postal_code).toBe("511 01");
|
||||||
|
}
|
||||||
|
const init = spy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||||
|
obchodniJmeno: "BOHA Automation",
|
||||||
|
pocet: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for a blank query without calling ARES", async () => {
|
||||||
|
const spy = vi.spyOn(globalThis, "fetch");
|
||||||
|
expect(await aresSearchByName(" ")).toEqual([]);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps upstream failure to ares_unavailable", async () => {
|
||||||
|
mockFetchOnce(503);
|
||||||
|
expect(await aresSearchByName("BOHA")).toEqual({
|
||||||
|
error: "ares_unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
153
src/admin/components/AresLookup.tsx
Normal file
153
src/admin/components/AresLookup.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
|
import { useAlert } from "../context/AlertContext";
|
||||||
|
|
||||||
|
/** Normalized company record returned by the /api/admin/ares proxy. */
|
||||||
|
export interface AresCompany {
|
||||||
|
ico: string;
|
||||||
|
dic: string | null;
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
postal_code: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AresAdornmentProps {
|
||||||
|
/** "ico" looks the query up directly; "name" searches and offers a picker. */
|
||||||
|
mode: "ico" | "name";
|
||||||
|
/** Current value of the host field (IČO or name fragment). */
|
||||||
|
query: string;
|
||||||
|
/** Called with the chosen company — the modal prefills its form from it. */
|
||||||
|
onFill: (company: AresCompany) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "ARES" end-adornment button for the Název/IČO fields of the customer and
|
||||||
|
* supplier modals. IČO mode fetches the subject directly; name mode searches
|
||||||
|
* ARES and shows a result picker (filling straight away on a single hit).
|
||||||
|
* Pass via the TextField's `InputProps.endAdornment`.
|
||||||
|
*/
|
||||||
|
export default function AresAdornment({
|
||||||
|
mode,
|
||||||
|
query,
|
||||||
|
onFill,
|
||||||
|
}: AresAdornmentProps) {
|
||||||
|
const alert = useAlert();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<AresCompany[]>([]);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const enabled =
|
||||||
|
mode === "ico"
|
||||||
|
? /^\d{8}$/.test(trimmed.replace(/\s+/g, ""))
|
||||||
|
: trimmed.length >= 2;
|
||||||
|
const tooltip =
|
||||||
|
mode === "ico"
|
||||||
|
? enabled
|
||||||
|
? "Načíst údaje z ARES podle IČO"
|
||||||
|
: "Vyplňte 8místné IČO"
|
||||||
|
: enabled
|
||||||
|
? "Vyhledat firmu v ARES podle názvu"
|
||||||
|
: "Vyplňte alespoň 2 znaky názvu";
|
||||||
|
|
||||||
|
const lookup = async () => {
|
||||||
|
if (!enabled || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (mode === "ico") {
|
||||||
|
const company = await jsonQuery<AresCompany>(
|
||||||
|
`/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`,
|
||||||
|
);
|
||||||
|
onFill(company);
|
||||||
|
} else {
|
||||||
|
const found = await jsonQuery<AresCompany[]>(
|
||||||
|
`/api/admin/ares/search?q=${encodeURIComponent(trimmed)}`,
|
||||||
|
);
|
||||||
|
if (found.length === 0) {
|
||||||
|
alert.error("Žádná firma tohoto názvu nebyla v ARES nalezena");
|
||||||
|
} else if (found.length === 1) {
|
||||||
|
onFill(found[0]);
|
||||||
|
} else {
|
||||||
|
setResults(found);
|
||||||
|
setMenuOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(
|
||||||
|
e instanceof Error ? e.message : "Registr ARES je nedostupný",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
size="small"
|
||||||
|
onClick={lookup}
|
||||||
|
disabled={!enabled || loading}
|
||||||
|
aria-label="Načíst z ARES"
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
color: enabled ? "primary.main" : "text.disabled",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ARES
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorRef.current}
|
||||||
|
open={menuOpen}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{results.map((c) => (
|
||||||
|
<MenuItem
|
||||||
|
key={c.ico}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
onFill(c);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{c.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
IČO {c.ico}
|
||||||
|
{c.city ? ` · ${c.city}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</InputAdornment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { styled } from "@mui/material/styles";
|
import { styled, useTheme } from "@mui/material/styles";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import {
|
import {
|
||||||
GridData,
|
GridData,
|
||||||
@@ -11,7 +12,8 @@ import {
|
|||||||
} from "../lib/queries/plan";
|
} from "../lib/queries/plan";
|
||||||
import type { Project } from "../lib/queries/projects";
|
import type { Project } from "../lib/queries/projects";
|
||||||
import PlanRangeChips from "./PlanRangeChips";
|
import PlanRangeChips from "./PlanRangeChips";
|
||||||
import { LoadingState } from "../ui";
|
import { LoadingState, Select, Field } from "../ui";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { fonts } from "../theme";
|
import { fonts } from "../theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
|
|||||||
border: `1px solid ${theme.vars!.palette.divider}`,
|
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
boxShadow: theme.shadows[2],
|
boxShadow: theme.shadows[2],
|
||||||
maxHeight: "calc(100dvh - 240px)",
|
// Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only
|
||||||
|
// ~360-400px, so the bare calc left <160px and the cells disappeared
|
||||||
|
// under the sticky header. The grid scrolls internally either way.
|
||||||
|
maxHeight: "max(calc(100dvh - 240px), 360px)",
|
||||||
isolation: "isolate",
|
isolation: "isolate",
|
||||||
|
|
||||||
"& .plan-grid": {
|
"& .plan-grid": {
|
||||||
@@ -464,6 +469,15 @@ export default function PlanGrid({
|
|||||||
pulseKey,
|
pulseKey,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
|
// Phone layout: the multi-person grid doesn't fit a portrait viewport, so
|
||||||
|
// a person picker shows ONE column at a time (Datum + selected person).
|
||||||
|
// Desktop/tablet keeps all columns.
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
const { user: authUser } = useAuth();
|
||||||
|
// null = "no explicit choice yet" → defaults to the logged-in user when
|
||||||
|
// they are in the plan, else the first person.
|
||||||
|
const [mobileUserId, setMobileUserId] = useState<number | null>(null);
|
||||||
const today = useMemo(() => todayIso(), []);
|
const today = useMemo(() => todayIso(), []);
|
||||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||||
// Index projects by id once per projects change instead of a linear
|
// Index projects by id once per projects change instead of a linear
|
||||||
@@ -483,7 +497,15 @@ export default function PlanGrid({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!data) return <LoadingState />;
|
if (!data) return <LoadingState />;
|
||||||
const users = data.users;
|
const allUsers = data.users;
|
||||||
|
// Mobile: narrow to the picked person (derived, not stored — a stale pick
|
||||||
|
// after the user list changes falls back gracefully).
|
||||||
|
const mobileUser =
|
||||||
|
allUsers.find((u) => u.id === mobileUserId) ??
|
||||||
|
allUsers.find((u) => u.id === authUser?.id) ??
|
||||||
|
allUsers[0];
|
||||||
|
const users =
|
||||||
|
isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers;
|
||||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||||
// mutation on the same cell re-triggers the animation (CSS animations
|
// mutation on the same cell re-triggers the animation (CSS animations
|
||||||
// don't restart unless the keyframe applies to a fresh element/class
|
// don't restart unless the keyframe applies to a fresh element/class
|
||||||
@@ -493,156 +515,172 @@ export default function PlanGrid({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlanGridRoot data-pulse={pulseAttr}>
|
<>
|
||||||
<table className="plan-grid">
|
{isMobile && allUsers.length > 1 && (
|
||||||
{/* The colgroup is what actually controls column width in a
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Field label="Osoba">
|
||||||
|
<Select
|
||||||
|
value={String(mobileUser?.id ?? "")}
|
||||||
|
onChange={(value) => setMobileUserId(Number(value))}
|
||||||
|
options={allUsers.map((u) => ({
|
||||||
|
value: String(u.id),
|
||||||
|
label: u.full_name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<PlanGridRoot data-pulse={pulseAttr}>
|
||||||
|
<table className="plan-grid">
|
||||||
|
{/* The colgroup is what actually controls column width in a
|
||||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||||
`table-layout: auto` happily blows past. The first column
|
`table-layout: auto` happily blows past. The first column
|
||||||
gets a fixed width via the col element so the date stamp
|
gets a fixed width via the col element so the date stamp
|
||||||
doesn't get stretched to share space with person columns. */}
|
doesn't get stretched to share space with person columns. */}
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="plan-grid-date-col" />
|
<col className="plan-grid-date-col" />
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<col key={u.id} />
|
<col key={u.id} />
|
||||||
))}
|
))}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="plan-grid-date-col">Datum</th>
|
<th className="plan-grid-date-col">Datum</th>
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const { first, last } = splitName(u.full_name);
|
const { first, last } = splitName(u.full_name);
|
||||||
return (
|
return (
|
||||||
<th key={u.id}>
|
<th key={u.id}>
|
||||||
<span className="plan-person-head">
|
<span className="plan-person-head">
|
||||||
<span className="plan-person-dot" aria-hidden />
|
<span className="plan-person-dot" aria-hidden />
|
||||||
<span className="plan-person-name">
|
<span className="plan-person-name">
|
||||||
<strong title={u.full_name}>
|
<strong title={u.full_name}>
|
||||||
{first}
|
{first}
|
||||||
{last ? ` ${last}` : ""}
|
{last ? ` ${last}` : ""}
|
||||||
</strong>
|
</strong>
|
||||||
{shortRole(u.role_name) && (
|
{shortRole(u.role_name) && (
|
||||||
<small>{shortRole(u.role_name)}</small>
|
<small>{shortRole(u.role_name)}</small>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</th>
|
||||||
</th>
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{days.map((date) => {
|
||||||
|
const dow = czechWeekday(date);
|
||||||
|
const dayNum = date.slice(8, 10);
|
||||||
|
const isToday = date === today;
|
||||||
|
const trCls = [
|
||||||
|
isWeekend(date) ? "plan-grid-weekend" : "",
|
||||||
|
isToday ? "is-today" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<tr key={date} className={trCls}>
|
||||||
|
<td className="plan-grid-date-col">
|
||||||
|
<span className="plan-date-stamp">
|
||||||
|
<span className="plan-date-daynum">{dayNum}</span>
|
||||||
|
<span className="plan-date-dow">{dow}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{users.map((u) => {
|
||||||
|
const cellArr = data.cells[u.id]?.[date] ?? [];
|
||||||
|
const cell = cellArr[0] ?? null;
|
||||||
|
const past = isPastDate(date, today);
|
||||||
|
// Past-day cells are read-only in the UI: an empty past
|
||||||
|
// cell is non-interactive (no create modal, no "+" hint),
|
||||||
|
// a past cell with data opens in view mode only. The
|
||||||
|
// server still enforces the past-date rule with a 403
|
||||||
|
// (defense in depth), but the click path here never
|
||||||
|
// reaches a create/edit submission for a past date.
|
||||||
|
const isLocked = !canEdit || past;
|
||||||
|
// `isPulsing` is true for the single (user, date) cell
|
||||||
|
// that the most recent successful mutation touched.
|
||||||
|
// CSS restarts the keyframe animation whenever the
|
||||||
|
// `nonce` changes (we embed it in data-pulse on the
|
||||||
|
// wrapper, see above), so back-to-back mutations on the
|
||||||
|
// same cell re-trigger the pulse.
|
||||||
|
const isPulsing =
|
||||||
|
!!pulseKey &&
|
||||||
|
pulseKey.userId === u.id &&
|
||||||
|
pulseKey.date === date;
|
||||||
|
let cls: string;
|
||||||
|
if (cell) {
|
||||||
|
cls = isLocked
|
||||||
|
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||||
|
: "plan-cell";
|
||||||
|
} else {
|
||||||
|
cls = isLocked
|
||||||
|
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||||
|
: "plan-cell plan-cell--empty";
|
||||||
|
}
|
||||||
|
if (isPulsing) cls += " plan-cell--pulse";
|
||||||
|
return (
|
||||||
|
<td key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
style={
|
||||||
|
cell
|
||||||
|
? ({
|
||||||
|
"--cat-color": catMap[cell.category]?.color,
|
||||||
|
} as CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => onCellClick(u.id, date, cellArr)}
|
||||||
|
aria-label={
|
||||||
|
cell
|
||||||
|
? cellArr.length === 1
|
||||||
|
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||||
|
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
|
||||||
|
: isLocked
|
||||||
|
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||||
|
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cellArr.map((c, i) => (
|
||||||
|
<Box
|
||||||
|
key={
|
||||||
|
c.entryId != null
|
||||||
|
? c.entryId
|
||||||
|
: c.overrideId != null
|
||||||
|
? `o${c.overrideId}`
|
||||||
|
: i
|
||||||
|
}
|
||||||
|
className="plan-cell-record"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--cat-color": catMap[c.category]?.color,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlanRangeChips
|
||||||
|
cell={c}
|
||||||
|
project={
|
||||||
|
c.project_id
|
||||||
|
? (projectMap.get(c.project_id) ?? null)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
categoryLabel={planCategoryLabel(
|
||||||
|
c.category,
|
||||||
|
catMap,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tbody>
|
||||||
</thead>
|
</table>
|
||||||
<tbody>
|
</PlanGridRoot>
|
||||||
{days.map((date) => {
|
</>
|
||||||
const dow = czechWeekday(date);
|
|
||||||
const dayNum = date.slice(8, 10);
|
|
||||||
const isToday = date === today;
|
|
||||||
const trCls = [
|
|
||||||
isWeekend(date) ? "plan-grid-weekend" : "",
|
|
||||||
isToday ? "is-today" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
return (
|
|
||||||
<tr key={date} className={trCls}>
|
|
||||||
<td className="plan-grid-date-col">
|
|
||||||
<span className="plan-date-stamp">
|
|
||||||
<span className="plan-date-daynum">{dayNum}</span>
|
|
||||||
<span className="plan-date-dow">{dow}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{users.map((u) => {
|
|
||||||
const cellArr = data.cells[u.id]?.[date] ?? [];
|
|
||||||
const cell = cellArr[0] ?? null;
|
|
||||||
const past = isPastDate(date, today);
|
|
||||||
// Past-day cells are read-only in the UI: an empty past
|
|
||||||
// cell is non-interactive (no create modal, no "+" hint),
|
|
||||||
// a past cell with data opens in view mode only. The
|
|
||||||
// server still enforces the past-date rule with a 403
|
|
||||||
// (defense in depth), but the click path here never
|
|
||||||
// reaches a create/edit submission for a past date.
|
|
||||||
const isLocked = !canEdit || past;
|
|
||||||
// `isPulsing` is true for the single (user, date) cell
|
|
||||||
// that the most recent successful mutation touched.
|
|
||||||
// CSS restarts the keyframe animation whenever the
|
|
||||||
// `nonce` changes (we embed it in data-pulse on the
|
|
||||||
// wrapper, see above), so back-to-back mutations on the
|
|
||||||
// same cell re-trigger the pulse.
|
|
||||||
const isPulsing =
|
|
||||||
!!pulseKey &&
|
|
||||||
pulseKey.userId === u.id &&
|
|
||||||
pulseKey.date === date;
|
|
||||||
let cls: string;
|
|
||||||
if (cell) {
|
|
||||||
cls = isLocked
|
|
||||||
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
|
||||||
: "plan-cell";
|
|
||||||
} else {
|
|
||||||
cls = isLocked
|
|
||||||
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
|
||||||
: "plan-cell plan-cell--empty";
|
|
||||||
}
|
|
||||||
if (isPulsing) cls += " plan-cell--pulse";
|
|
||||||
return (
|
|
||||||
<td key={u.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cls}
|
|
||||||
style={
|
|
||||||
cell
|
|
||||||
? ({
|
|
||||||
"--cat-color": catMap[cell.category]?.color,
|
|
||||||
} as CSSProperties)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => onCellClick(u.id, date, cellArr)}
|
|
||||||
aria-label={
|
|
||||||
cell
|
|
||||||
? cellArr.length === 1
|
|
||||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
|
||||||
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
|
|
||||||
: isLocked
|
|
||||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
|
||||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cellArr.map((c, i) => (
|
|
||||||
<Box
|
|
||||||
key={
|
|
||||||
c.entryId != null
|
|
||||||
? c.entryId
|
|
||||||
: c.overrideId != null
|
|
||||||
? `o${c.overrideId}`
|
|
||||||
: i
|
|
||||||
}
|
|
||||||
className="plan-cell-record"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--cat-color": catMap[c.category]?.color,
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlanRangeChips
|
|
||||||
cell={c}
|
|
||||||
project={
|
|
||||||
c.project_id
|
|
||||||
? (projectMap.get(c.project_id) ?? null)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
categoryLabel={planCategoryLabel(
|
|
||||||
c.category,
|
|
||||||
catMap,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</PlanGridRoot>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,7 +422,12 @@ export default function OdinChat() {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: "calc(100dvh - 100px)",
|
// svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
|
||||||
|
// dvh-sized full-height layout always lets the page scroll by the
|
||||||
|
// browser-chrome height. svh sizes for the bar-visible viewport — the
|
||||||
|
// page never scrolls and the chat's message list scrolls internally.
|
||||||
|
// Desktop: svh === vh.
|
||||||
|
height: "calc(100svh - 100px)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1163,7 +1163,16 @@ export default function InvoiceDetail() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
{/* flexWrap: long document titles must drop below the Zpět button
|
||||||
|
on phones instead of overflowing the viewport. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/invoices?tab=issued"
|
to="/invoices?tab=issued"
|
||||||
@@ -1724,7 +1733,16 @@ export default function InvoiceDetail() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
{/* flexWrap: long document titles must drop below the Zpět button
|
||||||
|
on phones instead of overflowing the viewport. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/invoices?tab=issued"
|
to="/invoices?tab=issued"
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -572,7 +573,16 @@ export default function IssuedOrderDetail() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
{/* flexWrap: long document titles must drop below the Zpět button on
|
||||||
|
phones instead of overflowing the viewport. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/orders?tab=vydane"
|
to="/orders?tab=vydane"
|
||||||
|
|||||||
@@ -620,7 +620,16 @@ export default function OfferDetail() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
{/* flexWrap: long document titles must drop below the Zpět button
|
||||||
|
on phones instead of overflowing the viewport. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/offers"
|
to="/offers"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type CustomField,
|
type CustomField,
|
||||||
} from "../lib/queries/offers";
|
} from "../lib/queries/offers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -267,6 +268,21 @@ export default function OffersCustomers() {
|
|||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
street: c.street,
|
||||||
|
city: c.city,
|
||||||
|
postal_code: c.postal_code,
|
||||||
|
country: c.country,
|
||||||
|
company_id: c.ico,
|
||||||
|
vat_id: c.dic || "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
setEditingCustomer(null);
|
setEditingCustomer(null);
|
||||||
setForm({
|
setForm({
|
||||||
@@ -535,6 +551,15 @@ export default function OffersCustomers() {
|
|||||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="Název firmy / jméno"
|
placeholder="Název firmy / jméno"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<AresAdornment
|
||||||
|
mode="name"
|
||||||
|
query={form.name}
|
||||||
|
onFill={fillFromAres}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ulice">
|
<Field label="Ulice">
|
||||||
@@ -596,6 +621,15 @@ export default function OffersCustomers() {
|
|||||||
company_id: e.target.value,
|
company_id: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<AresAdornment
|
||||||
|
mode="ico"
|
||||||
|
query={form.company_id}
|
||||||
|
onFill={fillFromAres}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="DIČ">
|
<Field label="DIČ">
|
||||||
|
|||||||
@@ -667,19 +667,29 @@ export default function PlanWork() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: stacked full-width rows (same pattern as headerActionsSx on
|
||||||
|
the detail pages); sm+ keeps the single wrapping toolbar row. The
|
||||||
|
paired controls (Dnes/arrows, Týden/Měsíc) stay together as one
|
||||||
|
full-width row each. */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexDirection: { xs: "column", sm: "row" },
|
||||||
alignItems: "center",
|
flexWrap: { sm: "wrap" },
|
||||||
|
alignItems: { xs: "stretch", sm: "center" },
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Nav buttons grouped so they stay on one row when the toolbar
|
{/* Nav buttons grouped so they stay on one row when the toolbar
|
||||||
wraps on mobile. */}
|
wraps on mobile. */}
|
||||||
<Box sx={{ display: "inline-flex", gap: 1 }}>
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
<Button variant="outlined" color="inherit" onClick={goToToday}>
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={goToToday}
|
||||||
|
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
|
||||||
|
>
|
||||||
Dnes
|
Dnes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -701,8 +711,8 @@ export default function PlanWork() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: { sm: 1 },
|
||||||
minWidth: 220,
|
minWidth: { sm: 220 },
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
@@ -727,7 +737,11 @@ export default function PlanWork() {
|
|||||||
<Box
|
<Box
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Měřítko zobrazení"
|
aria-label="Měřítko zobrazení"
|
||||||
sx={{ display: "inline-flex", gap: 1 }}
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 1,
|
||||||
|
"& > button": { flex: { xs: 1, sm: "0 0 auto" } },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant={view === "week" ? "contained" : "outlined"}
|
variant={view === "week" ? "contained" : "outlined"}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ export const theme = createTheme({
|
|||||||
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
h4: {
|
||||||
|
fontFamily: FONT_HEADING,
|
||||||
|
fontWeight: 700,
|
||||||
|
// Page/detail headlines: MUI's default 2.125rem overflows phone
|
||||||
|
// viewports (long document titles + number). Scale down on xs only.
|
||||||
|
"@media (max-width:600px)": { fontSize: "1.5rem" },
|
||||||
|
},
|
||||||
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
button: { textTransform: "none", fontWeight: 600 },
|
button: { textTransform: "none", fontWeight: 600 },
|
||||||
|
|||||||
16
src/main.tsx
16
src/main.tsx
@@ -4,6 +4,22 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
|
|
||||||
|
// Deploy-skew recovery (official Vite mechanism): after a release the old
|
||||||
|
// hashed chunks are deleted, so a tab still running the previous build fails
|
||||||
|
// its next lazy-page import. Vite emits `vite:preloadError` for exactly this —
|
||||||
|
// swallow the error and reload once, which loads the fresh index.html and new
|
||||||
|
// chunk names. The timestamp guard prevents a reload loop when a chunk is
|
||||||
|
// genuinely unloadable (e.g. offline): a second failure within 10 s falls
|
||||||
|
// through to the normal error path.
|
||||||
|
window.addEventListener("vite:preloadError", (event) => {
|
||||||
|
const KEY = "vite-preload-reloaded-at";
|
||||||
|
const last = Number(sessionStorage.getItem(KEY) || 0);
|
||||||
|
if (Date.now() - last < 10_000) return;
|
||||||
|
sessionStorage.setItem(KEY, String(Date.now()));
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter
|
<BrowserRouter
|
||||||
|
|||||||
63
src/routes/admin/ares.ts
Normal file
63
src/routes/admin/ares.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { requireAnyPermission } from "../../middleware/auth";
|
||||||
|
import { success, error } from "../../utils/response";
|
||||||
|
import { aresLookupByIco, aresSearchByName } from "../../services/ares.service";
|
||||||
|
|
||||||
|
// Czech messages for the service's token errors.
|
||||||
|
const ARES_ERRORS: Record<string, { message: string; status: number }> = {
|
||||||
|
invalid_ico: { message: "IČO musí být 8 číslic", status: 400 },
|
||||||
|
not_found: {
|
||||||
|
message: "Subjekt s tímto IČO nebyl v ARES nalezen",
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
ares_unavailable: {
|
||||||
|
message: "Registr ARES je momentálně nedostupný, zkuste to později",
|
||||||
|
status: 502,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARES proxy — the browser cannot call ares.gov.cz directly (CORS + CSP), so
|
||||||
|
* the customer/supplier modals go through these endpoints. Guarded by the
|
||||||
|
* permissions of the two modals that use the prefill (customer editing and
|
||||||
|
* warehouse supplier management); ARES data itself is public.
|
||||||
|
*/
|
||||||
|
export default async function aresRoutes(fastify: FastifyInstance) {
|
||||||
|
const guard = requireAnyPermission(
|
||||||
|
"customers.create",
|
||||||
|
"customers.edit",
|
||||||
|
"warehouse.manage",
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /ico/:ico — single subject by IČO
|
||||||
|
fastify.get<{ Params: { ico: string } }>(
|
||||||
|
"/ico/:ico",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (request, reply) => {
|
||||||
|
const result = await aresLookupByIco(request.params.ico);
|
||||||
|
if ("error" in result) {
|
||||||
|
const e = ARES_ERRORS[result.error];
|
||||||
|
return error(reply, e.message, e.status);
|
||||||
|
}
|
||||||
|
return success(reply, result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /search?q=… — subjects by (part of) business name, max 10
|
||||||
|
fastify.get<{ Querystring: { q?: string } }>(
|
||||||
|
"/search",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (request, reply) => {
|
||||||
|
const q = String(request.query.q ?? "").trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
return error(reply, "Zadejte alespoň 2 znaky názvu", 400);
|
||||||
|
}
|
||||||
|
const result = await aresSearchByName(q);
|
||||||
|
if ("error" in result && !Array.isArray(result)) {
|
||||||
|
const e = ARES_ERRORS[result.error];
|
||||||
|
return error(reply, e.message, e.status);
|
||||||
|
}
|
||||||
|
return success(reply, result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import projectFilesRoutes from "./routes/admin/project-files";
|
|||||||
import warehouseRoutes from "./routes/admin/warehouse";
|
import warehouseRoutes from "./routes/admin/warehouse";
|
||||||
import planRoutes from "./routes/admin/plan";
|
import planRoutes from "./routes/admin/plan";
|
||||||
import aiRoutes from "./routes/admin/ai";
|
import aiRoutes from "./routes/admin/ai";
|
||||||
|
import aresRoutes from "./routes/admin/ares";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -162,6 +163,7 @@ async function start() {
|
|||||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||||
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||||
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||||
|
await app.register(aresRoutes, { prefix: "/api/admin/ares" });
|
||||||
|
|
||||||
// --- Frontend: Vite dev middleware (dev only) ---
|
// --- Frontend: Vite dev middleware (dev only) ---
|
||||||
if (!config.isProduction) {
|
if (!config.isProduction) {
|
||||||
@@ -204,12 +206,35 @@ async function start() {
|
|||||||
root: path.join(__dirname, "..", "dist-client"),
|
root: path.join(__dirname, "..", "dist-client"),
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
|
// The plugin's own Cache-Control management must be OFF or it
|
||||||
|
// overwrites the setHeaders values below with "public, max-age=0"
|
||||||
|
// (README: "To provide a custom Cache-Control header, set this option
|
||||||
|
// to false"). ETags stay on, so no-cache still revalidates cheaply.
|
||||||
|
cacheControl: false,
|
||||||
|
// Deploy-skew caching contract (per the Vite deploy guide):
|
||||||
|
// - /assets/* names are content-hashed → safe to cache forever
|
||||||
|
// (a changed file always gets a NEW name, so "immutable" is correct).
|
||||||
|
// - index.html (and any non-hashed file) must always be revalidated,
|
||||||
|
// otherwise a cached copy keeps referencing deleted old chunks.
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
} else {
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setNotFoundHandler((request, reply) => {
|
app.setNotFoundHandler((request, reply) => {
|
||||||
if (request.url.startsWith("/api/")) {
|
if (request.url.startsWith("/api/")) {
|
||||||
return reply.status(404).send({ success: false, error: "Not found" });
|
return reply.status(404).send({ success: false, error: "Not found" });
|
||||||
}
|
}
|
||||||
|
// A missing hashed asset (old chunk after a deploy) must 404, NOT get
|
||||||
|
// the SPA index.html fallback — HTML-as-JS masks the failure and breaks
|
||||||
|
// the client's vite:preloadError reload recovery.
|
||||||
|
if (request.url.startsWith("/assets/")) {
|
||||||
|
return reply.status(404).send();
|
||||||
|
}
|
||||||
return reply.sendFile("index.html");
|
return reply.sendFile("index.html");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/services/ares.service.ts
Normal file
134
src/services/ares.service.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* ARES (Administrativní registr ekonomických subjektů) lookups — the public
|
||||||
|
* MFČR REST API for Czech company data. No auth required. Used by the
|
||||||
|
* customer/supplier modals to prefill company data from IČO or name.
|
||||||
|
*
|
||||||
|
* Docs: https://ares.gov.cz/swagger-ui (REST v3, ekonomicke-subjekty).
|
||||||
|
* The browser cannot call ares.gov.cz directly (CORS + our CSP connect-src),
|
||||||
|
* so these run server-side behind /api/admin/ares.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ARES_BASE = "https://ares.gov.cz/ekonomicke-subjekty-v-be/rest";
|
||||||
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/** Normalized subject shape consumed by the frontend prefill. */
|
||||||
|
export interface AresSubject {
|
||||||
|
ico: string;
|
||||||
|
dic: string | null;
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
postal_code: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AresSidlo {
|
||||||
|
nazevStatu?: string;
|
||||||
|
nazevObce?: string;
|
||||||
|
nazevCastiObce?: string;
|
||||||
|
nazevUlice?: string;
|
||||||
|
cisloDomovni?: number;
|
||||||
|
cisloOrientacni?: number;
|
||||||
|
cisloOrientacniPismeno?: string;
|
||||||
|
psc?: number;
|
||||||
|
textovaAdresa?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AresEkonomickySubjekt {
|
||||||
|
ico?: string;
|
||||||
|
obchodniJmeno?: string;
|
||||||
|
dic?: string;
|
||||||
|
sidlo?: AresSidlo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "51101" → "511 01" (Czech postal code display format). */
|
||||||
|
function formatPsc(psc: number | undefined): string {
|
||||||
|
if (!psc) return "";
|
||||||
|
const s = String(psc);
|
||||||
|
return s.length === 5 ? `${s.slice(0, 3)} ${s.slice(3)}` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Street line from the registered office: "Nádražní 485", Prague-style
|
||||||
|
* orientation numbers become "Ulice 485/12a". Villages without street names
|
||||||
|
* fall back to the municipality part name, then the municipality itself
|
||||||
|
* (matching how ARES itself builds textovaAdresa).
|
||||||
|
*/
|
||||||
|
function buildStreet(sidlo: AresSidlo): string {
|
||||||
|
const streetName =
|
||||||
|
sidlo.nazevUlice || sidlo.nazevCastiObce || sidlo.nazevObce || "";
|
||||||
|
let num = sidlo.cisloDomovni != null ? String(sidlo.cisloDomovni) : "";
|
||||||
|
if (sidlo.cisloOrientacni != null) {
|
||||||
|
num += `/${sidlo.cisloOrientacni}${sidlo.cisloOrientacniPismeno || ""}`;
|
||||||
|
}
|
||||||
|
return [streetName, num].filter(Boolean).join(" ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSubject(s: AresEkonomickySubjekt): AresSubject {
|
||||||
|
const sidlo = s.sidlo || {};
|
||||||
|
return {
|
||||||
|
ico: s.ico || "",
|
||||||
|
dic: s.dic || null,
|
||||||
|
name: s.obchodniJmeno || "",
|
||||||
|
street: buildStreet(sidlo),
|
||||||
|
city: sidlo.nazevObce || "",
|
||||||
|
postal_code: formatPsc(sidlo.psc),
|
||||||
|
country: sidlo.nazevStatu || "Česká republika",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AresError = "invalid_ico" | "not_found" | "ares_unavailable";
|
||||||
|
|
||||||
|
/** Lookup a single subject by its 8-digit IČO. */
|
||||||
|
export async function aresLookupByIco(
|
||||||
|
ico: string,
|
||||||
|
): Promise<AresSubject | { error: AresError }> {
|
||||||
|
const normalized = ico.replace(/\s+/g, "");
|
||||||
|
if (!/^\d{8}$/.test(normalized)) return { error: "invalid_ico" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${ARES_BASE}/ekonomicke-subjekty/${normalized}`,
|
||||||
|
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||||
|
);
|
||||||
|
if (response.status === 404) return { error: "not_found" };
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[ares] lookup ${normalized} failed: ${response.status}`);
|
||||||
|
return { error: "ares_unavailable" };
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as AresEkonomickySubjekt;
|
||||||
|
if (!data.ico) return { error: "not_found" };
|
||||||
|
return mapSubject(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ares] lookup failed:", err);
|
||||||
|
return { error: "ares_unavailable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search subjects by (part of) the business name; returns up to 10 matches. */
|
||||||
|
export async function aresSearchByName(
|
||||||
|
name: string,
|
||||||
|
): Promise<AresSubject[] | { error: AresError }> {
|
||||||
|
const query = name.trim();
|
||||||
|
if (!query) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ARES_BASE}/ekonomicke-subjekty/vyhledat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ obchodniJmeno: query, pocet: 10, start: 0 }),
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[ares] search "${query}" failed: ${response.status}`);
|
||||||
|
return { error: "ares_unavailable" };
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
ekonomickeSubjekty?: AresEkonomickySubjekt[];
|
||||||
|
};
|
||||||
|
return (data.ekonomickeSubjekty ?? []).map(mapSubject);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ares] search failed:", err);
|
||||||
|
return { error: "ares_unavailable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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