fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -300,7 +300,7 @@ export default function Attendance() {
|
||||
>({
|
||||
url: () => `${API_BASE}/leave-requests`,
|
||||
method: () => "POST",
|
||||
invalidate: ["attendance", "leave-requests", "users"],
|
||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
||||
});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -323,6 +323,10 @@ export default function Attendance() {
|
||||
const mountedRef = useRef(true);
|
||||
const latestActionRef = useRef<string | null>(null);
|
||||
|
||||
// Live wall-clock display (HH:MM) — re-render every 30s so the shown time
|
||||
// actually advances instead of freezing at first paint.
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -332,6 +336,11 @@ export default function Attendance() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Sync notes from query data when the shift changes
|
||||
useEffect(() => {
|
||||
if (statusQuery.data) {
|
||||
@@ -475,10 +484,20 @@ export default function Attendance() {
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")`
|
||||
// would parse as UTC midnight; reading it back with local getDay()/getDate()
|
||||
// can land on the wrong calendar day in the evening Prague window.
|
||||
const parseLocalDate = (s: string): Date | null => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
};
|
||||
|
||||
const calculateBusinessDays = (from: string, to: string) => {
|
||||
if (!from || !to) return 0;
|
||||
const start = new Date(from);
|
||||
const end = new Date(to);
|
||||
const start = parseLocalDate(from);
|
||||
const end = parseLocalDate(to);
|
||||
if (!start || !end) return 0;
|
||||
if (end < start) return 0;
|
||||
let days = 0;
|
||||
const current = new Date(start);
|
||||
@@ -673,7 +692,7 @@ export default function Attendance() {
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{new Date().toLocaleTimeString("cs-CZ", {
|
||||
{now.toLocaleTimeString("cs-CZ", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
|
||||
@@ -168,8 +168,6 @@ export default function AttendanceBalances() {
|
||||
userName: string;
|
||||
}>({ show: false, userId: null, userName: "" });
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
|
||||
const editMutation = useApiMutation<
|
||||
{
|
||||
user_id: string;
|
||||
@@ -199,6 +197,8 @@ export default function AttendanceBalances() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
|
||||
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
||||
setEditingUser({ id: userId, name: balance.name });
|
||||
setEditForm({
|
||||
@@ -767,7 +767,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vacation_total: parseFloat(e.target.value),
|
||||
vacation_total: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
|
||||
@@ -781,7 +781,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vacation_used: parseFloat(e.target.value),
|
||||
vacation_used: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||
@@ -795,7 +795,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
sick_used: parseFloat(e.target.value),
|
||||
sick_used: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function AttendanceCreate() {
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
leave_hours: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
|
||||
@@ -184,6 +184,13 @@ export default function AttendanceLocation() {
|
||||
|
||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||
|
||||
// Show the spinner while the record is still loading — this must precede the
|
||||
// `!record` null-return below, otherwise a pending query (record === undefined)
|
||||
// renders a blank page and this branch is dead.
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
@@ -196,10 +203,6 @@ export default function AttendanceLocation() {
|
||||
: record.shift_date;
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
|
||||
@@ -182,10 +182,6 @@ export default function AuditLog() {
|
||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||
const pagination = logsData?.pagination ?? null;
|
||||
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const cleanupMutation = useApiMutation<
|
||||
{ days: number; confirm?: string },
|
||||
{ message?: string; error?: string }
|
||||
@@ -199,6 +195,10 @@ export default function AuditLog() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
|
||||
@@ -1309,7 +1309,7 @@ export default function CompanySettings({
|
||||
onChange={(e) =>
|
||||
updateField("default_vat_rate", Number(e.target.value))
|
||||
}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Dostupné měny">
|
||||
|
||||
@@ -51,6 +51,7 @@ import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
||||
import { bankAccountsOptions } from "../lib/queries/common";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -69,6 +70,33 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
/**
|
||||
* Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time.
|
||||
* `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is
|
||||
* used. We build the Date from the local year/month/day parts (so it's a local
|
||||
* midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read
|
||||
* it back with local getters — never via `toISOString()`, which would shift a
|
||||
* day during the evening Prague window. See utils/formatters.todayLocalStr.
|
||||
*/
|
||||
function addDaysLocalStr(days: number, base?: string): string {
|
||||
let y: number, m: number, d: number;
|
||||
const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null;
|
||||
if (parts) {
|
||||
y = Number(parts[1]);
|
||||
m = Number(parts[2]) - 1;
|
||||
d = Number(parts[3]);
|
||||
} else {
|
||||
const now = new Date();
|
||||
y = now.getFullYear();
|
||||
m = now.getMonth();
|
||||
d = now.getDate();
|
||||
}
|
||||
const result = new Date(y, m, d + days);
|
||||
const mm = String(result.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(result.getDate()).padStart(2, "0");
|
||||
return `${result.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
issued: "Vystavena",
|
||||
paid: "Zaplacena",
|
||||
@@ -491,7 +519,7 @@ export default function InvoiceDetail() {
|
||||
customer_name: "",
|
||||
order_id: fromOrderId ? Number(fromOrderId) : null,
|
||||
issue_date: todayLocalStr(),
|
||||
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0],
|
||||
due_date: addDaysLocalStr(14),
|
||||
tax_date: todayLocalStr(),
|
||||
currency: "CZK",
|
||||
apply_vat: 1,
|
||||
@@ -641,15 +669,9 @@ export default function InvoiceDetail() {
|
||||
customer_id: inv.customer_id || null,
|
||||
customer_name: inv.customer_name || "",
|
||||
order_id: inv.order_id || null,
|
||||
issue_date: inv.issue_date
|
||||
? new Date(inv.issue_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
due_date: inv.due_date
|
||||
? new Date(inv.due_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
tax_date: inv.tax_date
|
||||
? new Date(inv.tax_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
issue_date: normalizeDateStr(inv.issue_date),
|
||||
due_date: normalizeDateStr(inv.due_date),
|
||||
tax_date: normalizeDateStr(inv.tax_date),
|
||||
currency: inv.currency || "CZK",
|
||||
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
||||
vat_rate: Number(inv.vat_rate) || 21,
|
||||
@@ -713,7 +735,7 @@ export default function InvoiceDetail() {
|
||||
bankAccountsQuery.isLoading,
|
||||
bankAccountsQuery.data,
|
||||
customersQuery.isLoading,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// Create mode: populate form from query data
|
||||
useEffect(() => {
|
||||
@@ -791,7 +813,7 @@ export default function InvoiceDetail() {
|
||||
orderDataQuery.data,
|
||||
companySettings,
|
||||
bankAccounts,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// Capture initial snapshot for dirty-checking once data sync completes.
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
@@ -817,9 +839,7 @@ export default function InvoiceDetail() {
|
||||
|
||||
const computedDueDate = useMemo(() => {
|
||||
if (!form.issue_date) return "";
|
||||
const d = new Date(form.issue_date);
|
||||
d.setDate(d.getDate() + dueDays);
|
||||
return d.toISOString().split("T")[0];
|
||||
return addDaysLocalStr(dueDays, form.issue_date);
|
||||
}, [form.issue_date, dueDays]);
|
||||
|
||||
// ─── Create mode: customer filtering ───
|
||||
|
||||
@@ -10,7 +10,13 @@ import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
czechPlural,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import {
|
||||
@@ -499,12 +505,14 @@ export default function Invoices() {
|
||||
};
|
||||
|
||||
// Per-row overdue tint (replaces offers-expired-row).
|
||||
// Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological)
|
||||
// to avoid the UTC-vs-local off-by-one near midnight Prague.
|
||||
const rowSx = (inv: Invoice) => {
|
||||
const isOverdue =
|
||||
inv.status === "overdue" ||
|
||||
(inv.status === "issued" &&
|
||||
inv.due_date &&
|
||||
new Date(inv.due_date) < new Date(new Date().toDateString()));
|
||||
!!inv.due_date &&
|
||||
normalizeDateStr(inv.due_date) < todayLocalStr());
|
||||
if (isOverdue) {
|
||||
return {
|
||||
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
||||
|
||||
@@ -155,8 +155,6 @@ export default function LeaveApproval() {
|
||||
}>({ open: false, request: null });
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const approveMutation = useApiMutation<
|
||||
{ id: number; status: "approved" },
|
||||
unknown
|
||||
@@ -184,6 +182,8 @@ export default function LeaveApproval() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approveModal.request) return;
|
||||
try {
|
||||
|
||||
@@ -99,10 +99,20 @@ export default function Login() {
|
||||
e.preventDefault();
|
||||
if (!totpCode.trim()) return;
|
||||
|
||||
// Defensive: the 2FA form only renders after a loginToken is issued, but
|
||||
// guard against a null token (e.g. server returns requires2FA without one)
|
||||
// rather than passing `null!` through to verify2FA.
|
||||
if (!loginToken) {
|
||||
alert.error("Relace vypršela, přihlaste se prosím znovu");
|
||||
setShow2FA(false);
|
||||
setTotpCode("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await verify2FA(
|
||||
loginToken!,
|
||||
loginToken,
|
||||
totpCode.trim(),
|
||||
remember,
|
||||
useBackupCode,
|
||||
@@ -131,6 +141,7 @@ export default function Login() {
|
||||
setLoading,
|
||||
setShake,
|
||||
setTotpCode,
|
||||
setShow2FA,
|
||||
setAnimatingOut,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -758,7 +758,7 @@ export default function OfferDetail() {
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [isEdit, id, isLockedByOther, isInvalidated]);
|
||||
}, [isEdit, id, isLockedByOther, isInvalidated, isCompleted]);
|
||||
|
||||
// Capture initial snapshot after loading completes (create mode)
|
||||
if (!loading && !initialSnapshotRef.current) {
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function OffersCustomers() {
|
||||
|
||||
return order.filter((key) => {
|
||||
if (key.startsWith("custom_")) {
|
||||
const idx = parseInt(key.split("_")[1]);
|
||||
const idx = parseInt(key.split("_")[1], 10);
|
||||
return idx < customFields.length;
|
||||
}
|
||||
return true;
|
||||
@@ -255,7 +255,7 @@ export default function OffersCustomers() {
|
||||
const getFieldDisplayName = (key: string) => {
|
||||
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key];
|
||||
if (key.startsWith("custom_")) {
|
||||
const idx = parseInt(key.split("_")[1]);
|
||||
const idx = parseInt(key.split("_")[1], 10);
|
||||
const cf = customFields[idx];
|
||||
if (cf)
|
||||
return cf.name
|
||||
@@ -672,7 +672,7 @@ export default function OffersCustomers() {
|
||||
.filter((k) => k !== key)
|
||||
.map((k) => {
|
||||
if (k.startsWith("custom_")) {
|
||||
const ki = parseInt(k.split("_")[1]);
|
||||
const ki = parseInt(k.split("_")[1], 10);
|
||||
if (ki > idx) return `custom_${ki - 1}`;
|
||||
}
|
||||
return k;
|
||||
|
||||
@@ -176,8 +176,6 @@ export default function OrderDetail() {
|
||||
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
||||
}, [order]);
|
||||
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||
url: () => `${API_BASE}/orders/${id}`,
|
||||
method: () => "PUT",
|
||||
@@ -199,6 +197,8 @@ export default function OrderDetail() {
|
||||
invalidate: ["orders", "invoices"],
|
||||
});
|
||||
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
const handleStatusChange = async () => {
|
||||
if (!statusConfirm.status) return;
|
||||
const newStatus = statusConfirm.status;
|
||||
@@ -462,8 +462,8 @@ export default function OrderDetail() {
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission("orders.edit") &&
|
||||
order.valid_transitions?.filter((s) => s !== "stornovana")
|
||||
.length! > 0 &&
|
||||
(order.valid_transitions?.filter((s) => s !== "stornovana")
|
||||
.length ?? 0) > 0 &&
|
||||
order
|
||||
.valid_transitions!.filter((s) => s !== "stornovana")
|
||||
.map((status) => (
|
||||
|
||||
@@ -174,8 +174,6 @@ export default function ProjectDetail() {
|
||||
}
|
||||
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
const projectSaveMutation = useApiMutation<
|
||||
{
|
||||
name: string;
|
||||
@@ -212,6 +210,8 @@ export default function ProjectDetail() {
|
||||
invalidate: ["projects", "warehouse"],
|
||||
});
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
const updateForm = (field: keyof ProjectForm, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
projectListOptions,
|
||||
@@ -117,6 +118,7 @@ export default function Projects() {
|
||||
|
||||
const { sort, order, handleSort } = useTableSort("project_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
@@ -216,7 +218,7 @@ export default function Projects() {
|
||||
isPending,
|
||||
isFetching,
|
||||
} = usePaginatedQuery<Project>(
|
||||
projectListOptions({ search, sort, order, page }),
|
||||
projectListOptions({ search: debouncedSearch, sort, order, page }),
|
||||
);
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import {
|
||||
companySettingsOptions,
|
||||
@@ -276,7 +277,14 @@ export default function ReceivedInvoices({
|
||||
|
||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
||||
const invoices = listQuery.data?.data ?? [];
|
||||
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
|
||||
|
||||
// Track first successful load (used to suppress the skeleton on later
|
||||
// refetches). Set in an effect rather than during render to avoid a
|
||||
// render-phase ref mutation.
|
||||
const hasData = !!(listQuery.data || statsQuery.data);
|
||||
useEffect(() => {
|
||||
if (hasData) hasLoadedOnce.current = true;
|
||||
}, [hasData]);
|
||||
|
||||
// Derive stats from query
|
||||
const stats = statsQuery.data ?? null;
|
||||
@@ -446,12 +454,11 @@ export default function ReceivedInvoices({
|
||||
}
|
||||
};
|
||||
|
||||
const toDateInput = (d: string | null | undefined): string => {
|
||||
if (!d) return "";
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
// Seed edit-form date inputs from stored values. Use normalizeDateStr (pure
|
||||
// string slice) instead of new Date(...).toISOString() — the latter converts
|
||||
// to UTC and is a day early in the late-evening Prague window.
|
||||
const toDateInput = (d: string | null | undefined): string =>
|
||||
normalizeDateStr(d);
|
||||
|
||||
const openEdit = (inv: ReceivedInvoice) => {
|
||||
setEditInvoice({
|
||||
|
||||
@@ -311,11 +311,6 @@ export default function Settings() {
|
||||
setSysFormInitialized(true);
|
||||
}, [sysSettingsData, sysFormInitialized]);
|
||||
|
||||
// ── Early return after all hooks ──
|
||||
if (!canAccessSettings) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const saveSystemSettingsMutation = useApiMutation<
|
||||
typeof sysForm,
|
||||
{ message?: string; error?: string }
|
||||
@@ -400,6 +395,11 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
// ── Early return after all hooks ──
|
||||
if (!canAccessSettings) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSaveSystemSettings = async () => {
|
||||
try {
|
||||
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
||||
|
||||
@@ -185,8 +185,6 @@ export default function Trips() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [, setLastKm] = useState(0);
|
||||
|
||||
if (!hasPermission("trips.record")) return <Forbidden />;
|
||||
|
||||
const submitMutation = useApiMutation<
|
||||
TripForm,
|
||||
{ message?: string; error?: string }
|
||||
@@ -214,6 +212,8 @@ export default function Trips() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("trips.record")) return <Forbidden />;
|
||||
|
||||
const fetchLastKm = async (vehicleId: string) => {
|
||||
if (!vehicleId) {
|
||||
setLastKm(0);
|
||||
@@ -284,7 +284,7 @@ export default function Trips() {
|
||||
if (
|
||||
form.start_km &&
|
||||
form.end_km &&
|
||||
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
|
||||
parseInt(String(form.end_km), 10) <= parseInt(String(form.start_km), 10)
|
||||
) {
|
||||
newErrors.end_km = "Musí být větší než počáteční";
|
||||
}
|
||||
@@ -312,8 +312,8 @@ export default function Trips() {
|
||||
};
|
||||
|
||||
const calculateDistance = (): number => {
|
||||
const start = parseInt(String(form.start_km)) || 0;
|
||||
const end = parseInt(String(form.end_km)) || 0;
|
||||
const start = parseInt(String(form.start_km), 10) || 0;
|
||||
const end = parseInt(String(form.end_km), 10) || 0;
|
||||
return end > start ? end - start : 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -228,8 +228,6 @@ export default function TripsAdmin() {
|
||||
);
|
||||
const trips = (tripsData ?? []).map(mapTrip);
|
||||
|
||||
if (!hasPermission("trips.manage")) return <Forbidden />;
|
||||
|
||||
// useApiMutation JSON.stringifies the whole TIn as the request body, so
|
||||
// TIn must match the backend schema (UpdateTripSchema) shape directly —
|
||||
// NOT a wrapper that nests the body. id is captured in the URL closure.
|
||||
@@ -265,6 +263,8 @@ export default function TripsAdmin() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("trips.manage")) return <Forbidden />;
|
||||
|
||||
const openEditModal = (trip: Trip) => {
|
||||
setEditingTrip(trip);
|
||||
setEditForm({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
Field,
|
||||
TextField,
|
||||
SwitchField,
|
||||
StatusChip,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
type DataColumn,
|
||||
@@ -261,12 +261,10 @@ export default function Vehicles() {
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
render: (v) => (
|
||||
<Chip
|
||||
<StatusChip
|
||||
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
||||
size="small"
|
||||
color={v.is_active ? "success" : "default"}
|
||||
onClick={() => toggleActive(v)}
|
||||
sx={{ cursor: "pointer", fontWeight: 600 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -401,7 +399,10 @@ export default function Vehicles() {
|
||||
value={form.initial_km}
|
||||
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
|
||||
setForm({
|
||||
...form,
|
||||
initial_km: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
warehouseReservationListOptions,
|
||||
warehouseMovementLogOptions,
|
||||
type WarehouseItem,
|
||||
type MovementLogRow,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
@@ -72,15 +73,8 @@ interface BelowMinRow {
|
||||
min_quantity: number;
|
||||
}
|
||||
|
||||
interface MovementRow {
|
||||
_idx: number;
|
||||
date: string;
|
||||
type: string;
|
||||
document_number: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
supplier_or_project: string;
|
||||
}
|
||||
// Dashboard movement row = the shared lib row plus a stable list index for rowKey.
|
||||
type MovementRow = MovementLogRow & { _idx: number };
|
||||
|
||||
export default function Warehouse() {
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
@@ -99,7 +99,6 @@ export default function WarehouseCategories() {
|
||||
show: boolean;
|
||||
category: WarehouseCategory | null;
|
||||
}>({ show: false, category: null });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submitMutation = useApiMutation<CategoryForm, void>({
|
||||
url: () =>
|
||||
@@ -156,13 +155,10 @@ export default function WarehouseCategories() {
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await submitMutation.mutateAsync(form);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,7 +270,7 @@ export default function WarehouseCategories() {
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
||||
loading={saving}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
@@ -302,12 +298,13 @@ export default function WarehouseCategories() {
|
||||
<TextField
|
||||
type="number"
|
||||
value={String(form.sort_order)}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setForm({
|
||||
...form,
|
||||
sort_order: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
sort_order: Number.isFinite(n) ? n : 0,
|
||||
});
|
||||
}}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -122,7 +122,12 @@ export default function WarehouseInventory() {
|
||||
header: "Položky",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
render: (s) => String(s.items?.length ?? 0),
|
||||
// List endpoint returns `_count` (not `items`), matching the issues/receipts lists.
|
||||
render: (s) =>
|
||||
String(
|
||||
(s as WarehouseInventorySession & { _count?: { items: number } })
|
||||
._count?.items ?? 0,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -67,13 +67,9 @@ export default function WarehouseInventoryDetail() {
|
||||
error,
|
||||
} = useQuery(warehouseInventoryDetailOptions(id));
|
||||
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
session
|
||||
? `/api/admin/warehouse/inventory-sessions/${session.id}/confirm`
|
||||
: "/api/admin/warehouse/inventory-sessions/0/confirm",
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (sessionId) =>
|
||||
`/api/admin/warehouse/inventory-sessions/${sessionId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -82,10 +78,13 @@ export default function WarehouseInventoryDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(session.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ interface InventoryItem {
|
||||
actual_qty: number;
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number {
|
||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -80,17 +86,6 @@ export default function WarehouseInventoryForm() {
|
||||
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const validate = (): boolean => {
|
||||
const validItems = items.filter((l) => l.item_id !== null);
|
||||
if (validItems.length === 0) {
|
||||
alert.error("Přidejte alespoň jednu položku");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const createMutation = useApiMutation<
|
||||
{
|
||||
notes: string | null;
|
||||
@@ -111,6 +106,24 @@ export default function WarehouseInventoryForm() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const validate = (): boolean => {
|
||||
const validItems = items.filter((l) => l.item_id !== null);
|
||||
if (validItems.length === 0) {
|
||||
alert.error("Přidejte alespoň jednu položku");
|
||||
return false;
|
||||
}
|
||||
for (const l of validItems) {
|
||||
if (!Number.isFinite(l.actual_qty) || l.actual_qty < 0) {
|
||||
alert.error("Skutečné množství musí být platné číslo");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
@@ -246,7 +259,7 @@ export default function WarehouseInventoryForm() {
|
||||
type="number"
|
||||
value={item.actual_qty || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(index, "actual_qty", Number(e.target.value))
|
||||
updateItem(index, "actual_qty", parseDecimal(e.target.value))
|
||||
}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
|
||||
@@ -70,15 +70,8 @@ export default function WarehouseIssueDetail() {
|
||||
error,
|
||||
} = useQuery(warehouseIssueDetailOptions(id));
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
issue
|
||||
? `/api/admin/warehouse/issues/${issue.id}/confirm`
|
||||
: "/api/admin/warehouse/issues/0/confirm",
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -86,11 +79,8 @@ export default function WarehouseIssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
issue
|
||||
? `/api/admin/warehouse/issues/${issue.id}/cancel`
|
||||
: "/api/admin/warehouse/issues/0/cancel",
|
||||
const cancelMutation = useApiMutation<number, void>({
|
||||
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/cancel`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -99,10 +89,15 @@ export default function WarehouseIssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!issue) return;
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(issue.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -111,7 +106,7 @@ export default function WarehouseIssueDetail() {
|
||||
const handleCancel = async () => {
|
||||
if (!issue) return;
|
||||
try {
|
||||
await cancelMutation.mutateAsync(undefined);
|
||||
await cancelMutation.mutateAsync(issue.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -315,8 +310,8 @@ export default function WarehouseIssueDetail() {
|
||||
{iss.project ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="a"
|
||||
href={`/projects/${iss.project.id}`}
|
||||
component={RouterLink}
|
||||
to={`/projects/${iss.project.id}`}
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 500,
|
||||
|
||||
@@ -264,6 +264,28 @@ export default function WarehouseIssueForm() {
|
||||
}
|
||||
}, [isEdit, issue]);
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/issues/${id}`
|
||||
: "/api/admin/warehouse/issues",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Výdejka byla potvrzena");
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||||
|
||||
const addItem = () => {
|
||||
@@ -332,31 +354,6 @@ export default function WarehouseIssueForm() {
|
||||
})),
|
||||
});
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/issues/${id}`
|
||||
: "/api/admin/warehouse/issues",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
confirmIssueId
|
||||
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
|
||||
: "/api/admin/warehouse/issues/0/confirm",
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Výdejka byla potvrzena");
|
||||
},
|
||||
});
|
||||
|
||||
const saveIssue = async (): Promise<number | null> => {
|
||||
try {
|
||||
const data = await saveMutation.mutateAsync(buildPayload());
|
||||
@@ -370,13 +367,10 @@ export default function WarehouseIssueForm() {
|
||||
};
|
||||
|
||||
const confirmIssue = async (issueId: number) => {
|
||||
setConfirmIssueId(issueId);
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(issueId);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setConfirmIssueId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -154,13 +154,6 @@ export default function WarehouseItemDetail() {
|
||||
|
||||
useModalLock(editing);
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canManage = hasPermission("warehouse.manage");
|
||||
|
||||
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
Record<string, unknown>,
|
||||
{ id?: number; message?: string }
|
||||
@@ -187,6 +180,14 @@ export default function WarehouseItemDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canManage = hasPermission("warehouse.manage");
|
||||
|
||||
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.name.trim()) newErrors.name = "Zadejte název položky";
|
||||
|
||||
@@ -233,13 +233,8 @@ export default function WarehouseLocations() {
|
||||
}
|
||||
|
||||
const total = locations.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1
|
||||
? "umístění"
|
||||
: total >= 2 && total <= 4
|
||||
? "umístění"
|
||||
: "umístění"
|
||||
}`;
|
||||
// "umístění" is invariant across grammatical number in Czech — no plural form.
|
||||
const subtitle = `${total} umístění`;
|
||||
|
||||
const columns: DataColumn<WarehouseLocation>[] = [
|
||||
{
|
||||
|
||||
@@ -94,15 +94,8 @@ export default function WarehouseReceiptDetail() {
|
||||
error,
|
||||
} = useQuery(warehouseReceiptDetailOptions(id));
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
receipt
|
||||
? `/api/admin/warehouse/receipts/${receipt.id}/confirm`
|
||||
: "/api/admin/warehouse/receipts/0/confirm",
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -110,11 +103,8 @@ export default function WarehouseReceiptDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
receipt
|
||||
? `/api/admin/warehouse/receipts/${receipt.id}/cancel`
|
||||
: "/api/admin/warehouse/receipts/0/cancel",
|
||||
const cancelMutation = useApiMutation<number, void>({
|
||||
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/cancel`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -124,13 +114,11 @@ export default function WarehouseReceiptDetail() {
|
||||
});
|
||||
|
||||
const deleteAttachmentMutation = useApiMutation<
|
||||
{ attachmentId: number },
|
||||
{ receiptId: number; attachmentId: number },
|
||||
void
|
||||
>({
|
||||
url: ({ attachmentId }) =>
|
||||
receipt
|
||||
? `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`
|
||||
: `/api/admin/warehouse/receipts/0/attachments/0`,
|
||||
url: ({ receiptId, attachmentId }) =>
|
||||
`/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -138,10 +126,15 @@ export default function WarehouseReceiptDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(receipt.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -150,7 +143,7 @@ export default function WarehouseReceiptDetail() {
|
||||
const handleCancel = async () => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
await cancelMutation.mutateAsync(undefined);
|
||||
await cancelMutation.mutateAsync(receipt.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -159,7 +152,10 @@ export default function WarehouseReceiptDetail() {
|
||||
const handleDeleteAttachment = async (attachmentId: number) => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
await deleteAttachmentMutation.mutateAsync({ attachmentId });
|
||||
await deleteAttachmentMutation.mutateAsync({
|
||||
receiptId: receipt.id,
|
||||
attachmentId,
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
|
||||
@@ -200,6 +200,75 @@ export default function WarehouseReceiptForm() {
|
||||
}
|
||||
}, [isEdit, receipt]);
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/receipts/${id}`
|
||||
: "/api/admin/warehouse/receipts",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Doklad byl potvrzen");
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
if (!isEdit || !id) return;
|
||||
setUploadingFiles(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/receipts/${id}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
alert.error(
|
||||
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
alert.success("Soubory byly nahrány");
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setUploadingFiles(false);
|
||||
}
|
||||
},
|
||||
[id, isEdit, alert, queryClient],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFileUpload(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||||
|
||||
const addItem = () => {
|
||||
@@ -266,31 +335,6 @@ export default function WarehouseReceiptForm() {
|
||||
})),
|
||||
});
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/receipts/${id}`
|
||||
: "/api/admin/warehouse/receipts",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const [confirmReceiptId, setConfirmReceiptId] = useState<number | null>(null);
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
confirmReceiptId
|
||||
? `/api/admin/warehouse/receipts/${confirmReceiptId}/confirm`
|
||||
: "/api/admin/warehouse/receipts/0/confirm",
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Doklad byl potvrzen");
|
||||
},
|
||||
});
|
||||
|
||||
const saveReceipt = async (): Promise<number | null> => {
|
||||
try {
|
||||
const data = await saveMutation.mutateAsync(buildPayload());
|
||||
@@ -304,13 +348,10 @@ export default function WarehouseReceiptForm() {
|
||||
};
|
||||
|
||||
const confirmReceipt = async (receiptId: number) => {
|
||||
setConfirmReceiptId(receiptId);
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(receiptId);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setConfirmReceiptId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -342,53 +383,6 @@ export default function WarehouseReceiptForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
if (!isEdit || !id) return;
|
||||
setUploadingFiles(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/receipts/${id}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
alert.error(
|
||||
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
alert.success("Soubory byly nahrány");
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setUploadingFiles(false);
|
||||
}
|
||||
},
|
||||
[id, isEdit, alert, queryClient],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFileUpload(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
if (isEdit && isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
warehouseStockStatusOptions,
|
||||
warehouseBelowMinimumOptions,
|
||||
warehouseCategoryListOptions,
|
||||
warehouseMovementLogOptions,
|
||||
type WarehouseItem,
|
||||
type MovementLogRow,
|
||||
} from "../lib/queries/warehouse";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency } from "../utils/formatters";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
@@ -43,16 +46,6 @@ interface ProjectConsumptionRow {
|
||||
total_value: number;
|
||||
}
|
||||
|
||||
interface MovementLogRow {
|
||||
date: string;
|
||||
type: string;
|
||||
document_number: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supplier_or_project: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ value: "stock-status", label: "Stav zásob" },
|
||||
{ value: "project-consumption", label: "Spotřeba projektů" },
|
||||
@@ -260,34 +253,36 @@ function ProjectConsumptionTab({
|
||||
name: string;
|
||||
}[]) ?? [];
|
||||
|
||||
const [data, setData] = useState<ProjectConsumptionRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
// Filters applied on "Zobrazit"; the query key carries them so results land in
|
||||
// the ["warehouse"] cache and refresh on warehouse mutations.
|
||||
const [applied, setApplied] = useState<{
|
||||
projectId: number | "";
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setFetched(true);
|
||||
try {
|
||||
const {
|
||||
data,
|
||||
isFetching: loading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["warehouse", "project-consumption", applied],
|
||||
enabled: applied !== null,
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectId) params.set("project_id", String(projectId));
|
||||
if (dateFrom) params.set("date_from", dateFrom);
|
||||
if (dateTo) params.set("date_to", dateTo);
|
||||
if (applied?.projectId)
|
||||
params.set("project_id", String(applied.projectId));
|
||||
if (applied?.dateFrom) params.set("date_from", applied.dateFrom);
|
||||
if (applied?.dateTo) params.set("date_to", applied.dateTo);
|
||||
const qs = params.toString();
|
||||
const response = await apiFetch(
|
||||
return jsonQuery<ProjectConsumptionRow[]>(
|
||||
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setData(result.data ?? []);
|
||||
} else {
|
||||
setData([]);
|
||||
}
|
||||
} catch {
|
||||
setData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const fetched = applied !== null;
|
||||
const handleFetch = () => setApplied({ projectId, dateFrom, dateTo });
|
||||
|
||||
const columns: DataColumn<ProjectConsumptionRow & { _idx: number }>[] = [
|
||||
{
|
||||
@@ -352,7 +347,15 @@ function ProjectConsumptionTab({
|
||||
|
||||
{fetched && loading && <LoadingState />}
|
||||
|
||||
{fetched && !loading && data && (
|
||||
{fetched && !loading && error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Nepodařilo se načíst spotřebu projektů"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetched && !loading && !error && data && (
|
||||
<Card>
|
||||
<DataTable<ProjectConsumptionRow & { _idx: number }>
|
||||
columns={columns}
|
||||
@@ -383,34 +386,29 @@ function MovementLogTab({
|
||||
dateTo: string;
|
||||
setDateTo: (v: string) => void;
|
||||
}) {
|
||||
const [data, setData] = useState<MovementLogRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
// Filters applied on "Zobrazit"; uses the shared movement-log query option so
|
||||
// results land in the ["warehouse"] cache and refresh on warehouse mutations.
|
||||
const [applied, setApplied] = useState<{
|
||||
type: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setFetched(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set("type", type);
|
||||
if (dateFrom) params.set("date_from", dateFrom);
|
||||
if (dateTo) params.set("date_to", dateTo);
|
||||
const qs = params.toString();
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setData(result.data ?? []);
|
||||
} else {
|
||||
setData([]);
|
||||
}
|
||||
} catch {
|
||||
setData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
data,
|
||||
isFetching: loading,
|
||||
error,
|
||||
} = useQuery({
|
||||
...warehouseMovementLogOptions({
|
||||
type: applied?.type || undefined,
|
||||
date_from: applied?.dateFrom || undefined,
|
||||
date_to: applied?.dateTo || undefined,
|
||||
}),
|
||||
enabled: applied !== null,
|
||||
});
|
||||
|
||||
const fetched = applied !== null;
|
||||
const handleFetch = () => setApplied({ type, dateFrom, dateTo });
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
receipt: "Příjem",
|
||||
@@ -423,7 +421,7 @@ function MovementLogTab({
|
||||
header: "Datum",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (row) => row.date,
|
||||
render: (row) => formatDate(row.date),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
@@ -501,7 +499,15 @@ function MovementLogTab({
|
||||
|
||||
{fetched && loading && <LoadingState />}
|
||||
|
||||
{fetched && !loading && data && (
|
||||
{fetched && !loading && error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Nepodařilo se načíst pohyby"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetched && !loading && !error && data && (
|
||||
<Card>
|
||||
<DataTable<MovementLogRow & { _idx: number }>
|
||||
columns={columns}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -9,12 +9,12 @@ import IconButton from "@mui/material/IconButton";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import ItemPicker from "../components/warehouse/ItemPicker";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
warehouseReservationListOptions,
|
||||
type WarehouseReservation,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { projectListOptions } from "../lib/queries/projects";
|
||||
import {
|
||||
Button,
|
||||
@@ -106,11 +106,17 @@ interface ReservationForm {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface CreateReservationPayload {
|
||||
item_id: number;
|
||||
project_id: number;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export default function WarehouseReservations() {
|
||||
const navigate = useNavigate();
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
@@ -148,6 +154,29 @@ export default function WarehouseReservations() {
|
||||
}),
|
||||
);
|
||||
|
||||
const createMutation = useApiMutation<CreateReservationPayload, void>({
|
||||
url: () => "/api/admin/warehouse/reservations",
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
setShowCreateModal(false);
|
||||
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
|
||||
alert.success("Rezervace byla vytvořena");
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useApiMutation<number, void>({
|
||||
url: (reservationId) =>
|
||||
`/api/admin/warehouse/reservations/${reservationId}/cancel`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
setCancelTarget(null);
|
||||
alert.success("Rezervace byla zrušena");
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
@@ -161,33 +190,25 @@ export default function WarehouseReservations() {
|
||||
const hasActiveFilters = statusFilter || projectId;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.item_id || !form.project_id || form.quantity <= 0) {
|
||||
if (
|
||||
!form.item_id ||
|
||||
!form.project_id ||
|
||||
!Number.isFinite(form.quantity) ||
|
||||
form.quantity <= 0
|
||||
) {
|
||||
alert.error("Vyplňte položku, projekt a množství");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await apiFetch("/api/admin/warehouse/reservations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
item_id: form.item_id,
|
||||
project_id: form.project_id,
|
||||
quantity: form.quantity,
|
||||
notes: form.notes.trim() || null,
|
||||
}),
|
||||
await createMutation.mutateAsync({
|
||||
item_id: form.item_id,
|
||||
project_id: form.project_id,
|
||||
quantity: form.quantity,
|
||||
notes: form.notes.trim() || null,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
setShowCreateModal(false);
|
||||
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
|
||||
alert.success("Rezervace byla vytvořena");
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se vytvořit rezervaci");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -197,20 +218,9 @@ export default function WarehouseReservations() {
|
||||
if (!cancelTarget) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/reservations/${cancelTarget.id}/cancel`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
setCancelTarget(null);
|
||||
alert.success("Rezervace byla zrušena");
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se zrušit rezervaci");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
await cancelMutation.mutateAsync(cancelTarget.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
@@ -451,12 +461,13 @@ export default function WarehouseReservations() {
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.quantity || ""}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
quantity: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
quantity: Number.isFinite(n) ? n : 0,
|
||||
}));
|
||||
}}
|
||||
inputProps={{ min: 0, step: 0.001 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -129,10 +129,6 @@ export default function WarehouseSuppliers() {
|
||||
show: boolean;
|
||||
supplier: WarehouseSupplier | null;
|
||||
}>({ show: false, supplier: null });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggleActiveSupplierId, setToggleActiveSupplierId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const submitMutation = useApiMutation<
|
||||
SupplierForm,
|
||||
@@ -158,14 +154,14 @@ export default function WarehouseSuppliers() {
|
||||
},
|
||||
});
|
||||
|
||||
// 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<
|
||||
{ is_active: boolean },
|
||||
{ id: number; is_active: boolean },
|
||||
{ message?: string }
|
||||
>({
|
||||
url: () => {
|
||||
if (!toggleActiveSupplierId) return API_BASE;
|
||||
return `${API_BASE}/${toggleActiveSupplierId}`;
|
||||
},
|
||||
url: ({ id }) => `${API_BASE}/${id}`,
|
||||
method: () => "PUT",
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
@@ -210,13 +206,10 @@ export default function WarehouseSuppliers() {
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await submitMutation.mutateAsync(form);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,9 +224,9 @@ export default function WarehouseSuppliers() {
|
||||
};
|
||||
|
||||
const toggleActive = async (supplier: WarehouseSupplier) => {
|
||||
setToggleActiveSupplierId(supplier.id);
|
||||
try {
|
||||
await toggleActiveMutation.mutateAsync({
|
||||
id: supplier.id,
|
||||
is_active: !supplier.is_active,
|
||||
});
|
||||
alert.success(
|
||||
@@ -243,8 +236,6 @@ export default function WarehouseSuppliers() {
|
||||
);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setToggleActiveSupplierId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -403,7 +394,7 @@ export default function WarehouseSuppliers() {
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
||||
loading={saving}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
|
||||
Reference in New Issue
Block a user