Compare commits

...

11 Commits

Author SHA1 Message Date
BOHA
2dbacc3bec chore(release): v2.4.10 - mobile UX fixes (plan grid, Odin, toolbar)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:54:36 +02:00
BOHA
c21f02e5d1 fix(mobile): plan landscape collapse + person picker, Odin page scroll, plan toolbar stacking
- PlanGrid maxHeight gets a 360px floor: calc(100dvh - 240px) left
  <160px in phone landscape and the rows vanished under the sticky
  header
- Phones get a person picker above the plan grid (one column at a
  time, defaults to the logged-in user) - the multi-person grid never
  fit a portrait viewport; desktop unchanged
- Odin chat height uses 100svh instead of 100dvh: dvh grows live as
  Android Chrome collapses its address bar, so the page always allowed
  a chrome-height scroll; svh sizes for the bar-visible viewport
  (verified zero overflow at 390x844 via Playwright)
- Plan toolbar stacks into full-width rows on xs (headerActionsSx
  pattern from the detail pages); Dnes/arrows and Tyden/Mesic stay
  paired on one row each

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:53:52 +02:00
BOHA
f87e110359 chore(release): v2.4.9 - cache-header hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:37 +02:00
BOHA
f1b1329c1b fix(spa): cacheControl:false so custom cache headers actually apply
@fastify/static's default Cache-Control management overwrote the
setHeaders values with 'public, max-age=0' (verified on prod after the
v2.4.8 deploy) - the README requires disabling it for custom headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:31 +02:00
BOHA
88d5d43448 chore(release): v2.4.8 - deploy-skew self-healing (stale chunk auto-reload + cache headers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:32:49 +02:00
BOHA
4745af3639 fix(spa): self-heal stale clients after deploys (vite:preloadError + cache headers)
After a release the old hashed chunks are deleted, so an open tab from
the previous build failed its next lazy-page import until a manual
refresh. Three-part fix per the Vite deploy guide:

- vite:preloadError listener reloads the page once (10s sessionStorage
  guard against loops) so stale tabs heal themselves
- static cache headers: /assets/* immutable for a year (content-hashed
  names), index.html no-cache so new visits always see fresh chunk refs
- missing /assets/* now 404 instead of getting the SPA index.html
  fallback (HTML-as-JS masked the failure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:31:59 +02:00
BOHA
4258699b73 chore(release): v2.4.7 - mobile detail-header overflow fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:22:24 +02:00
BOHA
bfd2c59ad3 fix(ui): detail-form headers no longer overflow on mobile
Two causes: theme h4 had no responsive size (desktop 2.125rem on
phones - now 1.5rem under 600px, applies to all page headlines), and
the Zpet+title header row had no flexWrap so long document titles
pushed past the viewport (issued orders, offers, both invoice views).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:21:29 +02:00
BOHA
0b69dbfde0 chore(release): v2.4.6 - ARES company lookup, suppliers on the customers model (structured address + custom fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:12:57 +02:00
BOHA
db7a5c3d15 feat(suppliers)!: full customers model - structured address + custom fields
The dodavatele modal is now an exact mirror of the customers modal
(minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme,
ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md).

Two migrations on sklad_suppliers:
- structured street/city/postal_code/country replace the free-text
  address blob (best-effort split: street / PSC regex / city)
- custom_fields LONGTEXT replaces contact_person/email/phone/notes;
  existing values are preserved as labeled custom fields

The encode/decode of the custom_fields blob is shared with customers
via the new utils/custom-fields.ts. The PO PDF supplier block renders
the structured address + custom-field lines like the customer block;
the supplier picker/detail selects and types follow. Legacy clients
sending the dropped keys are silently stripped (tested). Suppliers
list shows Ulice/Mesto instead of the dropped contact columns; search
covers name/ico/city.

BREAKING CHANGE: sklad_suppliers.address, contact_person, email,
phone, notes columns dropped (data migrated); deploy must run
prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:22 +02:00
BOHA
f1ce76d21d feat(ares): ARES company lookup with prefill in customer/supplier modals
New /api/admin/ares proxy (browser cannot reach ares.gov.cz - CORS+CSP):
GET /ico/:ico and GET /search?q= map the MFCR REST API to the form
shape (street incl. orientation numbers, "511 01" PSC format, DIC).
Guarded by customers.create/edit + warehouse.manage; token errors map
to Czech messages. Shared AresAdornment button sits in the Nazev and
ICO fields of both modals: ICO mode fills directly, name mode searches
with a result picker. Tested against live ARES (BOHA, ICO 22599851).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:08 +02:00
31 changed files with 1383 additions and 397 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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`;

View File

@@ -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`;

View File

@@ -808,11 +808,11 @@ model sklad_suppliers {
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)

116
src/__tests__/ares.test.ts Normal file
View 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",
});
});
});

View File

@@ -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Č: ");
}); });

View File

@@ -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", () => {

View 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>
);
}

View File

@@ -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,6 +515,21 @@ export default function PlanGrid({
: undefined; : undefined;
return ( return (
<>
{isMobile && allUsers.length > 1 && (
<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}> <PlanGridRoot data-pulse={pulseAttr}>
<table className="plan-grid"> <table className="plan-grid">
{/* The colgroup is what actually controls column width in a {/* The colgroup is what actually controls column width in a
@@ -644,5 +681,6 @@ export default function PlanGrid({
</tbody> </tbody>
</table> </table>
</PlanGridRoot> </PlanGridRoot>
</>
); );
} }

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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Č">

View File

@@ -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"}

View File

@@ -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>
{/* Dynamic custom fields — same editor as the customers modal */}
<Box sx={{ mt: 0.5 }}>
<Typography
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
Vlastní pole
</Typography>
{customFields.map((field, idx) => (
<Box key={field._key} sx={{ mb: 1 }}>
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2, gap: 2,
alignItems: "flex-start",
}} }}
> >
<Field label="Kontaktní osoba">
<TextField <TextField
value={form.contact_person} label={idx === 0 ? "Název" : undefined}
onChange={(e) => value={field.name}
setForm({ ...form, contact_person: e.target.value }) 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))
} }
placeholder="Jan Novák" title="Odebrat pole"
/> aria-label="Odebrat pole"
</Field> >
<Field label="E-mail"> {RemoveIcon}
<TextField </IconButton>
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="info@firma.cz"
/>
</Field>
</Box> </Box>
</Box>
<Field label="Telefon"> <Box sx={{ mt: 0.5 }}>
<TextField <CheckboxField
type="tel" label={
value={form.phone} <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
onChange={(e) => setForm({ ...form, phone: e.target.value })} Zobrazit název v PDF
placeholder="+420 123 456 789" </Typography>
}
checked={field.showLabel !== false}
onChange={(checked) => {
const updated = [...customFields];
updated[idx] = { ...updated[idx], showLabel: checked };
setCustomFields(updated);
}}
/> />
</Field> </Box>
</Box>
<Field label="Adresa"> ))}
<TextField <Button
multiline variant="outlined"
minRows={3} color="inherit"
value={form.address} size="small"
onChange={(e) => setForm({ ...form, address: e.target.value })} startIcon={SmallPlusIcon}
placeholder="Ulice, město, PSČ" onClick={() =>
/> setCustomFields([
</Field> ...customFields,
{
<Field label="Poznámky"> name: "",
<TextField value: "",
multiline showLabel: true,
minRows={3} _key: `cf-${++customFieldKeyCounter.current}`,
value={form.notes} },
onChange={(e) => setForm({ ...form, notes: e.target.value })} ])
placeholder="Volitelné poznámky" }
/> sx={{ mt: 0.5 }}
</Field> >
Přidat pole
</Button>
</Box>
</Modal> </Modal>
{/* Deactivate/Delete Confirmation */} {/* Deactivate/Delete Confirmation */}

View File

@@ -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 },

View File

@@ -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
View 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);
},
);
}

View File

@@ -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(

View File

@@ -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 };
} }

View File

@@ -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" }],

View File

@@ -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,
}, },
}); });

View File

@@ -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>;

View File

@@ -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");
}); });
} }

View 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" };
}
}

View File

@@ -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" } },

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