feat(suppliers)!: full customers model - structured address + custom fields
The dodavatele modal is now an exact mirror of the customers modal (minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme, ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md). Two migrations on sklad_suppliers: - structured street/city/postal_code/country replace the free-text address blob (best-effort split: street / PSC regex / city) - custom_fields LONGTEXT replaces contact_person/email/phone/notes; existing values are preserved as labeled custom fields The encode/decode of the custom_fields blob is shared with customers via the new utils/custom-fields.ts. The PO PDF supplier block renders the structured address + custom-field lines like the customer block; the supplier picker/detail selects and types follow. Legacy clients sending the dropped keys are silently stripped (tested). Suppliers list shows Ulice/Mesto instead of the dropped contact columns; search covers name/ico/city. BREAKING CHANGE: sklad_suppliers.address, contact_person, email, phone, notes columns dropped (data migrated); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -207,9 +207,10 @@ export default function IssuedOrderDetail() {
|
||||
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
|
||||
ico: null,
|
||||
dic: null,
|
||||
address: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
street: null,
|
||||
city: null,
|
||||
postal_code: null,
|
||||
country: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
warehouseSupplierListOptions,
|
||||
type WarehouseSupplier,
|
||||
type WarehouseSupplierCustomField,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
CheckboxField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
@@ -32,17 +35,29 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin/warehouse/suppliers";
|
||||
|
||||
// Mirror of the customers modal (minus the PDF field-order picker): the same
|
||||
// company fields + "Vlastní pole"; contact person/e-mail/phone live as custom
|
||||
// fields since the dedicated columns were dropped.
|
||||
interface SupplierForm {
|
||||
name: string;
|
||||
ico: string;
|
||||
dic: string;
|
||||
contact_person: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
const EMPTY_SUPPLIER_FORM: SupplierForm = {
|
||||
name: "",
|
||||
ico: "",
|
||||
dic: "",
|
||||
street: "",
|
||||
city: "",
|
||||
postal_code: "",
|
||||
country: "",
|
||||
};
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const PlusIcon = (
|
||||
@@ -88,6 +103,32 @@ const DeleteIcon = (
|
||||
<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>
|
||||
);
|
||||
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() {
|
||||
const alert = useAlert();
|
||||
@@ -113,16 +154,11 @@ export default function WarehouseSuppliers() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] =
|
||||
useState<WarehouseSupplier | null>(null);
|
||||
const [form, setForm] = useState<SupplierForm>({
|
||||
name: "",
|
||||
ico: "",
|
||||
dic: "",
|
||||
contact_person: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
const [form, setForm] = useState<SupplierForm>({ ...EMPTY_SUPPLIER_FORM });
|
||||
const [customFields, setCustomFields] = useState<
|
||||
WarehouseSupplierCustomField[]
|
||||
>([]);
|
||||
const customFieldKeyCounter = useRef(0);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [deactivateConfirm, setDeactivateConfirm] = useState<{
|
||||
@@ -131,7 +167,13 @@ export default function WarehouseSuppliers() {
|
||||
}>({ show: false, supplier: null });
|
||||
|
||||
const submitMutation = useApiMutation<
|
||||
SupplierForm,
|
||||
SupplierForm & {
|
||||
custom_fields: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
showLabel?: boolean;
|
||||
}>;
|
||||
},
|
||||
{ id?: number; message?: string }
|
||||
>({
|
||||
url: () =>
|
||||
@@ -174,18 +216,26 @@ export default function WarehouseSuppliers() {
|
||||
|
||||
if (!hasPermission("warehouse.manage")) return <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 = () => {
|
||||
setEditingSupplier(null);
|
||||
setForm({
|
||||
name: "",
|
||||
ico: "",
|
||||
dic: "",
|
||||
contact_person: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
setForm({ ...EMPTY_SUPPLIER_FORM });
|
||||
setCustomFields([]);
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -196,12 +246,19 @@ export default function WarehouseSuppliers() {
|
||||
name: supplier.name,
|
||||
ico: supplier.ico || "",
|
||||
dic: supplier.dic || "",
|
||||
contact_person: supplier.contact_person || "",
|
||||
email: supplier.email || "",
|
||||
phone: supplier.phone || "",
|
||||
address: supplier.address || "",
|
||||
notes: supplier.notes || "",
|
||||
street: supplier.street || "",
|
||||
city: supplier.city || "",
|
||||
postal_code: supplier.postal_code || "",
|
||||
country: supplier.country || "",
|
||||
});
|
||||
setCustomFields(
|
||||
Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0
|
||||
? supplier.custom_fields.map((f) => ({
|
||||
...f,
|
||||
_key: `cf-${++customFieldKeyCounter.current}`,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -213,7 +270,16 @@ export default function WarehouseSuppliers() {
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
try {
|
||||
await submitMutation.mutateAsync(form);
|
||||
await submitMutation.mutateAsync({
|
||||
...form,
|
||||
custom_fields: customFields
|
||||
.filter((f) => f.name.trim() || f.value.trim())
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
showLabel: f.showLabel,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -281,23 +347,16 @@ export default function WarehouseSuppliers() {
|
||||
render: (s) => s.dic || "—",
|
||||
},
|
||||
{
|
||||
key: "contact_person",
|
||||
header: "Kontaktní osoba",
|
||||
width: "16%",
|
||||
render: (s) => s.contact_person || "—",
|
||||
key: "street",
|
||||
header: "Ulice",
|
||||
width: "18%",
|
||||
render: (s) => s.street || "—",
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
header: "E-mail",
|
||||
width: "16%",
|
||||
render: (s) => s.email || "—",
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
header: "Telefon",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (s) => s.phone || "—",
|
||||
key: "city",
|
||||
header: "Město",
|
||||
width: "14%",
|
||||
render: (s) => s.city || "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@@ -394,13 +453,15 @@ export default function WarehouseSuppliers() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{/* Add/Edit Modal — mirror of the customers modal (minus the PDF
|
||||
field-order picker) */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
||||
loading={submitMutation.isPending}
|
||||
maxWidth="md"
|
||||
>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
@@ -410,7 +471,53 @@ export default function WarehouseSuppliers() {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
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>
|
||||
|
||||
@@ -425,72 +532,129 @@ export default function WarehouseSuppliers() {
|
||||
<TextField
|
||||
value={form.ico}
|
||||
onChange={(e) => setForm({ ...form, ico: e.target.value })}
|
||||
placeholder="12345678"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="ico"
|
||||
query={form.ico}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DIČ">
|
||||
<TextField
|
||||
value={form.dic}
|
||||
onChange={(e) => setForm({ ...form, dic: e.target.value })}
|
||||
placeholder="CZ12345678"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Kontaktní osoba">
|
||||
<TextField
|
||||
value={form.contact_person}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, contact_person: e.target.value })
|
||||
}
|
||||
placeholder="Jan Novák"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="E-mail">
|
||||
<TextField
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="info@firma.cz"
|
||||
/>
|
||||
</Field>
|
||||
{/* 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
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Deactivate/Delete Confirmation */}
|
||||
|
||||
Reference in New Issue
Block a user