- OrderDetail/InvoiceDetail/LeaveRequests mutations now invalidate the same domain sets as their list-page twins (orders+offers+projects+invoices / +projects / +users+dashboard) — no more stale project/order labels after a detail-page action. - USER_INVALIDATE hoisted to lib/queries/users.ts; DashProfile self-edit now refreshes trips/attendance/leave/projects like an admin edit. - Global QueryCache.onError toast: failed first-loads no longer masquerade as empty lists (data-present refetches stay silent; Unauthorized skipped; 4s rate limit; meta.suppressGlobalErrorToast opt-out used by offer/issued-order detail + the local TOTP QR query). - Users/Vehicles active-toggle mutations toast on error (was silent revert); clickable status chips got affordance tooltips (incl. warehouse parity). - AuditLog: refetchOnMount always + staleTime 0 (mutations write audit rows but nothing invalidates the key); search debounced; dead auditLogOptions module removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
687 lines
19 KiB
TypeScript
687 lines
19 KiB
TypeScript
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 {
|
|
Button,
|
|
Card,
|
|
DataTable,
|
|
Pagination,
|
|
Modal,
|
|
ConfirmDialog,
|
|
Field,
|
|
TextField,
|
|
CheckboxField,
|
|
StatusChip,
|
|
PageHeader,
|
|
PageEnter,
|
|
FilterBar,
|
|
EmptyState,
|
|
LoadingState,
|
|
type DataColumn,
|
|
} from "../ui";
|
|
|
|
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;
|
|
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 = (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
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 EditIcon = (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
);
|
|
const DeleteIcon = (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<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();
|
|
const { hasPermission } = useAuth();
|
|
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState("");
|
|
const debouncedSearch = useDebounce(search, 300);
|
|
|
|
const {
|
|
items: suppliers,
|
|
pagination,
|
|
isPending,
|
|
isFetching,
|
|
} = usePaginatedQuery<WarehouseSupplier>(
|
|
warehouseSupplierListOptions({
|
|
search: debouncedSearch || undefined,
|
|
page,
|
|
perPage: PER_PAGE,
|
|
}),
|
|
);
|
|
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingSupplier, setEditingSupplier] =
|
|
useState<WarehouseSupplier | null>(null);
|
|
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<{
|
|
show: boolean;
|
|
supplier: WarehouseSupplier | null;
|
|
}>({ show: false, supplier: null });
|
|
|
|
const submitMutation = useApiMutation<
|
|
SupplierForm & {
|
|
custom_fields: Array<{
|
|
name: string;
|
|
value: string;
|
|
showLabel?: boolean;
|
|
}>;
|
|
},
|
|
{ id?: number; message?: string }
|
|
>({
|
|
url: () =>
|
|
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
|
|
method: () => (editingSupplier ? "PUT" : "POST"),
|
|
// issued-orders included: the PO form's supplier picker caches under
|
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
|
invalidate: ["warehouse", "issued-orders"],
|
|
onSuccess: (data) => {
|
|
setShowModal(false);
|
|
alert.success(data?.message || "Dodavatel byl uložen");
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useApiMutation<number, { message?: string }>({
|
|
url: (id) => `${API_BASE}/${id}`,
|
|
method: () => "DELETE",
|
|
// issued-orders included: the PO form's supplier picker caches under
|
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
|
invalidate: ["warehouse", "issued-orders"],
|
|
onSuccess: (data) => {
|
|
setDeactivateConfirm({ show: false, supplier: null });
|
|
alert.success(data?.message || "Dodavatel byl smazán");
|
|
},
|
|
});
|
|
|
|
// id is captured from the mutation variable (built into the URL), so it stays
|
|
// correct even when called immediately after a state update. The non-strict
|
|
// UpdateSupplierSchema strips the `id` field from the body.
|
|
const toggleActiveMutation = useApiMutation<
|
|
{ id: number; is_active: boolean },
|
|
{ message?: string }
|
|
>({
|
|
url: ({ id }) => `${API_BASE}/${id}`,
|
|
method: () => "PUT",
|
|
// issued-orders included: the PO form's supplier picker caches under
|
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
|
invalidate: ["warehouse", "issued-orders"],
|
|
});
|
|
|
|
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({ ...EMPTY_SUPPLIER_FORM });
|
|
setCustomFields([]);
|
|
setErrors({});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openEditModal = (supplier: WarehouseSupplier) => {
|
|
setEditingSupplier(supplier);
|
|
setForm({
|
|
name: supplier.name,
|
|
ico: supplier.ico || "",
|
|
dic: supplier.dic || "",
|
|
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);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
const newErrors: Record<string, string> = {};
|
|
if (!form.name.trim()) newErrors.name = "Zadejte název dodavatele";
|
|
setErrors(newErrors);
|
|
if (Object.keys(newErrors).length > 0) return;
|
|
|
|
try {
|
|
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í");
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async () => {
|
|
if (!deactivateConfirm.supplier) return;
|
|
|
|
try {
|
|
await deleteMutation.mutateAsync(deactivateConfirm.supplier.id);
|
|
} catch (e) {
|
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
|
}
|
|
};
|
|
|
|
const toggleActive = async (supplier: WarehouseSupplier) => {
|
|
try {
|
|
await toggleActiveMutation.mutateAsync({
|
|
id: supplier.id,
|
|
is_active: !supplier.is_active,
|
|
});
|
|
alert.success(
|
|
supplier.is_active
|
|
? "Dodavatel byl deaktivován"
|
|
: "Dodavatel byl aktivován",
|
|
);
|
|
} catch (e) {
|
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
|
}
|
|
};
|
|
|
|
if (isPending) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
const total = pagination?.total ?? suppliers.length;
|
|
const subtitle = `${total} ${
|
|
total === 1
|
|
? "dodavatel"
|
|
: total >= 2 && total <= 4
|
|
? "dodavatelé"
|
|
: "dodavatelů"
|
|
}`;
|
|
|
|
const columns: DataColumn<WarehouseSupplier>[] = [
|
|
{
|
|
key: "name",
|
|
header: "Název",
|
|
width: "20%",
|
|
bold: true,
|
|
render: (s) => s.name,
|
|
},
|
|
{
|
|
key: "ico",
|
|
header: "IČO",
|
|
width: "11%",
|
|
mono: true,
|
|
render: (s) => s.ico || "—",
|
|
},
|
|
{
|
|
key: "dic",
|
|
header: "DIČ",
|
|
width: "11%",
|
|
mono: true,
|
|
render: (s) => s.dic || "—",
|
|
},
|
|
{
|
|
key: "street",
|
|
header: "Ulice",
|
|
width: "18%",
|
|
render: (s) => s.street || "—",
|
|
},
|
|
{
|
|
key: "city",
|
|
header: "Město",
|
|
width: "14%",
|
|
render: (s) => s.city || "—",
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "Stav",
|
|
width: "8%",
|
|
render: (s) => (
|
|
<StatusChip
|
|
label={s.is_active ? "Aktivní" : "Neaktivní"}
|
|
color={s.is_active ? "success" : "default"}
|
|
onClick={() => toggleActive(s)}
|
|
title="Kliknutím přepnete stav"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: "Akce",
|
|
width: "10%",
|
|
align: "right",
|
|
render: (s) => (
|
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => openEditModal(s)}
|
|
aria-label="Upravit"
|
|
title="Upravit"
|
|
>
|
|
{EditIcon}
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
color="error"
|
|
onClick={() => setDeactivateConfirm({ show: true, supplier: s })}
|
|
aria-label={s.is_active ? "Deaktivovat" : "Smazat"}
|
|
title={s.is_active ? "Deaktivovat" : "Smazat"}
|
|
>
|
|
{DeleteIcon}
|
|
</IconButton>
|
|
</Box>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageEnter>
|
|
<PageHeader
|
|
title="Dodavatelé"
|
|
subtitle={subtitle}
|
|
actions={
|
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
|
Přidat dodavatele
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<FilterBar>
|
|
<Box sx={{ flex: "1 1 320px" }}>
|
|
<TextField
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
placeholder="Hledat dodavatele..."
|
|
fullWidth
|
|
/>
|
|
</Box>
|
|
</FilterBar>
|
|
|
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
|
<DataTable<WarehouseSupplier>
|
|
columns={columns}
|
|
rows={suppliers}
|
|
rowKey={(s) => s.id}
|
|
rowInactive={(s) => !s.is_active}
|
|
empty={
|
|
search ? (
|
|
<EmptyState title={`Žádní dodavatelé pro „${search}".`} />
|
|
) : (
|
|
<EmptyState
|
|
title="Zatím nejsou žádní dodavatelé."
|
|
action={
|
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
|
Přidat prvního dodavatele
|
|
</Button>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
<Pagination
|
|
page={page}
|
|
pageCount={pagination?.total_pages ?? 1}
|
|
onChange={setPage}
|
|
/>
|
|
</Card>
|
|
|
|
{/* 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
|
|
value={form.name}
|
|
error={!!errors.name}
|
|
onChange={(e) => {
|
|
setForm({ ...form, name: e.target.value });
|
|
setErrors((prev) => ({ ...prev, name: "" }));
|
|
}}
|
|
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>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<Field label="IČO">
|
|
<TextField
|
|
value={form.ico}
|
|
onChange={(e) => setForm({ ...form, ico: e.target.value })}
|
|
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 })}
|
|
/>
|
|
</Field>
|
|
</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
|
|
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>
|
|
</Modal>
|
|
|
|
{/* Deactivate/Delete Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={deactivateConfirm.show}
|
|
onClose={() => setDeactivateConfirm({ show: false, supplier: null })}
|
|
onConfirm={handleDeactivate}
|
|
title={
|
|
deactivateConfirm.supplier?.is_active
|
|
? "Deaktivovat dodavatele"
|
|
: "Smazat dodavatele"
|
|
}
|
|
message={
|
|
deactivateConfirm.supplier
|
|
? deactivateConfirm.supplier.is_active
|
|
? `Opravdu chcete deaktivovat dodavatele "${deactivateConfirm.supplier.name}"?`
|
|
: `Opravdu chcete smazat dodavatele "${deactivateConfirm.supplier.name}"? Tato akce je nevratná.`
|
|
: ""
|
|
}
|
|
confirmText={
|
|
deactivateConfirm.supplier?.is_active ? "Deaktivovat" : "Smazat"
|
|
}
|
|
confirmVariant="danger"
|
|
loading={deleteMutation.isPending}
|
|
/>
|
|
</PageEnter>
|
|
);
|
|
}
|