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

@@ -34,10 +34,11 @@ export interface InvoiceStats {
export interface InvoiceItem {
id?: number;
description: string;
item_description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
vat_rate?: number;
is_included_in_total: boolean;
position?: number;
}

View File

@@ -0,0 +1,113 @@
import {
useMutation,
useQueryClient,
type UseMutationOptions,
} from "@tanstack/react-query";
import apiFetch from "../../utils/api";
export interface ApiError extends Error {
status?: number;
}
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
interface ApiResponseBody<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
async function performMutation<TIn, TOut>(opts: {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
body?: TIn;
}): Promise<TOut> {
const url =
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
const method =
typeof opts.method === "function"
? opts.method(opts.body as TIn)
: opts.method;
const response = await apiFetch(url, {
method,
headers:
opts.body !== undefined
? { "Content-Type": "application/json" }
: undefined,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (response.status === 401) {
const err: ApiError = new Error("Unauthorized");
err.status = 401;
throw err;
}
let result: ApiResponseBody<TOut>;
try {
result = (await response.json()) as ApiResponseBody<TOut>;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
const err: ApiError = new Error(
result.error || `Request failed (${response.status})`,
);
err.status = response.status;
throw err;
}
return result.data as TOut;
}
export interface ApiMutationOptions<TIn, TOut> {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
invalidate?: readonly string[];
}
/**
* Hook that wraps `useMutation` with `apiFetch` and broad-invalidation.
*
* Usage:
* const createCategory = useApiMutation<CategoryForm, WarehouseCategory>({
* url: "/api/admin/warehouse/categories",
* method: "POST",
* invalidate: ["warehouse"],
* });
* createCategory.mutate(form, {
* onSuccess: () => { ... },
* });
*
* The returned `data` from a successful mutation is `TOut`. Errors are thrown
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
*/
export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) {
const { url, method, invalidate, onSuccess, ...rest } = opts;
const queryClient = useQueryClient();
return useMutation<TOut, ApiError, TIn, unknown>({
mutationFn: (input: TIn) =>
performMutation<TIn, TOut>({ url, method, body: input }),
...rest,
onSuccess: (data, variables, onMutateResult, context) => {
if (invalidate) {
for (const key of invalidate) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
// Forward to user-provided onSuccess with the 4-arg signature expected by TanStack.
(
onSuccess as
| ((d: TOut, v: TIn, r: unknown, c: unknown) => void)
| undefined
)?.(data, variables, onMutateResult, context);
},
});
}

View File

@@ -145,6 +145,9 @@ export const offerDetailOptions = (id: string | undefined) =>
queryKey: ["offers", id],
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
enabled: !!id,
// 404s (deleted/missing offers) are not transient. Retrying just spams
// GETs and fires the detail-page redirect toast repeatedly.
retry: false,
});
export const offerNextNumberOptions = () =>

View File

@@ -28,6 +28,12 @@ export interface ProjectData {
project_notes?: ProjectNote[];
}
export interface Project {
id: number;
project_number: string;
name: string;
}
export const projectListOptions = (filters: {
search?: string;
sort?: string;
@@ -45,7 +51,9 @@ export const projectListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/projects${qs ? `?${qs}` : ""}`);
return paginatedJsonQuery<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`,
);
},
});

View File

@@ -48,6 +48,12 @@ export interface CompanySettingsData {
offer_number_pattern?: string;
order_number_pattern?: string;
invoice_number_pattern?: string;
warehouse_receipt_prefix?: string;
warehouse_receipt_number_pattern?: string;
warehouse_issue_prefix?: string;
warehouse_issue_number_pattern?: string;
warehouse_inventory_prefix?: string;
warehouse_inventory_number_pattern?: string;
// Misc
app_version?: string;

View File

@@ -37,11 +37,12 @@ export interface WarehouseReceipt {
first_name: string;
last_name: string;
} | null;
lines?: WarehouseReceiptLine[];
_count?: { items: number };
items?: WarehouseReceiptItem[];
attachments?: WarehouseReceiptAttachment[];
}
export interface WarehouseReceiptLine {
export interface WarehouseReceiptItem {
id: number;
receipt_id: number;
item_id: number;
@@ -74,10 +75,11 @@ export interface WarehouseIssue {
modified_at: string | null;
project?: { id: number; name: string; project_number: string } | null;
issued_by_user?: { id: number; first_name: string; last_name: string } | null;
lines?: WarehouseIssueLine[];
_count?: { items: number };
items?: WarehouseIssueItem[];
}
export interface WarehouseIssueLine {
export interface WarehouseIssueItem {
id: number;
issue_id: number;
item_id: number;
@@ -124,10 +126,10 @@ export interface WarehouseInventorySession {
status: "DRAFT" | "CONFIRMED";
created_at: string;
modified_at: string | null;
lines?: WarehouseInventoryLine[];
items?: WarehouseInventoryItem[];
}
export interface WarehouseInventoryLine {
export interface WarehouseInventoryItem {
id: number;
session_id: number;
item_id: number;
@@ -168,6 +170,16 @@ export interface WarehouseSupplier {
is_active: boolean;
}
export interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
// --- Query Options ---
export const warehouseItemListOptions = (filters: {
@@ -196,11 +208,14 @@ export const warehouseItemListOptions = (filters: {
},
});
export const warehouseItemDetailOptions = (id: string | undefined) =>
export const warehouseItemDetailOptions = (
id: string | undefined,
enabled?: boolean,
) =>
queryOptions({
queryKey: ["warehouse", "items", id],
queryFn: () => jsonQuery<WarehouseItem>(`/api/admin/warehouse/items/${id}`),
enabled: !!id,
enabled: enabled ?? !!id,
});
export const warehouseCategoryListOptions = () =>
@@ -229,11 +244,17 @@ export const warehouseSupplierListOptions = (filters: {
},
});
export const warehouseLocationListOptions = () =>
export const warehouseLocationListOptions = (filters?: {
active?: "true" | "false" | "all";
}) =>
queryOptions({
queryKey: ["warehouse", "locations"],
queryFn: () =>
jsonQuery<WarehouseLocation[]>("/api/admin/warehouse/locations"),
queryKey: ["warehouse", "locations", filters ?? { active: "true" }],
queryFn: () => {
const active = filters?.active ?? "true";
return jsonQuery<WarehouseLocation[]>(
`/api/admin/warehouse/locations?active=${active}`,
);
},
});
export const warehouseReceiptListOptions = (filters: {
@@ -346,7 +367,7 @@ export const warehouseInventoryListOptions = (filters: {
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory${qs ? `?${qs}` : ""}`,
`/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`,
);
},
});
@@ -356,7 +377,7 @@ export const warehouseInventoryDetailOptions = (id: string | undefined) =>
queryKey: ["warehouse", "inventory", id],
queryFn: () =>
jsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory/${id}`,
`/api/admin/warehouse/inventory-sessions/${id}`,
),
enabled: !!id,
});
@@ -372,7 +393,7 @@ export const warehouseStockStatusOptions = (filters?: {
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return jsonQuery<WarehouseItem[]>(
`/api/admin/warehouse/stock-status${qs ? `?${qs}` : ""}`,
`/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`,
);
},
});
@@ -381,5 +402,29 @@ export const warehouseBelowMinimumOptions = () =>
queryOptions({
queryKey: ["warehouse", "below-minimum"],
queryFn: () =>
jsonQuery<WarehouseItem[]>("/api/admin/warehouse/below-minimum"),
jsonQuery<WarehouseItem[]>("/api/admin/warehouse/reports/below-minimum"),
});
export const warehouseMovementLogOptions = (filters?: {
limit?: number;
type?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "movement-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.type) params.set("type", filters.type);
if (filters?.project_id)
params.set("project_id", String(filters.project_id));
if (filters?.date_from) params.set("date_from", filters.date_from);
if (filters?.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return jsonQuery<MovementLogRow[]>(
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
);
},
});