v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix

Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -56,6 +56,12 @@ export const apiFetch = async (
url: string,
options: RequestInit = {},
): Promise<Response> => {
// Honour abort signals up-front — TanStack Query may cancel a stale
// request before we've done any work.
if (options.signal?.aborted) {
return new Response(null, { status: 499 });
}
let token: string | null = null;
try {
token = state.getTokenFn ? state.getTokenFn() : null;
@@ -63,6 +69,36 @@ export const apiFetch = async (
// token retrieval failed
}
// No usable token yet (e.g. fresh page load where the silent refresh
// kicked off in useEffect hasn't populated the in-memory access token).
// Refresh up front, BEFORE firing the request, so the caller never sees
// a 401 in the network log on the first attempt. Concurrent callers join
// the same in-flight refresh via state.refreshPromise.
if (!token && state.refreshFn) {
try {
if (!state.refreshPromise) {
state.refreshPromise = state.refreshFn().finally(() => {
state.refreshPromise = null;
});
}
const refreshed = await state.refreshPromise;
if (options.signal?.aborted) {
return new Response(null, { status: 499 });
}
if (refreshed) {
try {
token = state.getTokenFn ? state.getTokenFn() : null;
} catch {
token = null;
}
}
} catch {
// Refresh itself failed — fall through and let the request fire
// without a token. The 401 handler below will set a session-expired
// flag so the UI can react.
}
}
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
@@ -86,6 +122,12 @@ export const apiFetch = async (
});
if (response.status === 401 && state.refreshFn) {
// If the caller already unmounted (TanStack Query aborted the request),
// do not refresh + retry. Doing so would fire a global "session expired"
// toast on a page the user has already left and leak a fetch+refresh pair.
if (options.signal?.aborted) {
return response;
}
try {
if (!state.refreshPromise) {
state.refreshPromise = state.refreshFn().finally(() => {
@@ -93,12 +135,17 @@ export const apiFetch = async (
});
}
const refreshed = await state.refreshPromise;
// Re-check after the await: the caller may have unmounted while the
// refresh was in flight (refresh + abort race).
if (options.signal?.aborted) {
return response;
}
if (refreshed) {
token = state.getTokenFn ? state.getTokenFn() : null;
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const { signal, ...retryOptions } = options;
const { signal: _signal, ...retryOptions } = options;
response = await fetch(url, {
...retryOptions,
headers,