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

@@ -9,6 +9,13 @@ interface Batch {
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
@@ -20,29 +27,48 @@ export default function BatchPicker({
value,
onChange,
}: BatchPickerProps) {
const { data: batches } = useQuery({
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<Batch[]>(`/api/admin/warehouse/items/${itemId}/batches`),
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const batchId = Number(e.target.value);
const batch = batches?.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
>
<option value="">-- auto FIFO --</option>
{batches?.map((b) => (
<option key={b.id} value={b.id}>
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
ks | {Number(b.unit_price).toFixed(2)}
</option>
))}
</select>
<div>
{response && response.reserved_quantity > 0 && (
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</div>
)}
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const batchId = Number(e.target.value);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
>
<option value="">-- auto FIFO --</option>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
ks | {Number(b.unit_price).toFixed(2)}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,59 +1,228 @@
import { useState } from "react";
import { useState, useRef, useEffect, useCallback, useId } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
interface ItemPickerProps {
value: number | null;
onChange: (itemId: number) => void;
itemName?: string;
}
export default function ItemPicker({ value, onChange }: ItemPickerProps) {
const [search, setSearch] = useState("");
export default function ItemPicker({
value,
onChange,
itemName,
}: ItemPickerProps) {
const [search, setSearch] = useState(itemName ?? "");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
const containerRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef<number>(-1);
const listboxId = useId();
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [dropdownStyle, setDropdownStyle] = useState<{
position: "fixed";
top: number;
left: number;
width: number;
zIndex: number;
} | null>(null);
const updatePosition = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 2,
left: rect.left,
width: rect.width,
zIndex: 100,
});
}, []);
// Reset active index when items change
useEffect(() => {
if (activeIndex >= items.length) {
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [items, activeIndex]);
useEffect(() => {
if (open) {
updatePosition();
const onScroll = () => updatePosition();
const onClose = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onClose);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onClose);
};
} else {
setDropdownStyle(null);
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [open, updatePosition]);
// Close on click-outside via mousedown on document
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target;
if (containerRef.current && target instanceof Node) {
if (!containerRef.current.contains(target)) {
// Don't close if click landed on an option in the portal
const listEl = document.getElementById(listboxId);
if (listEl && listEl.contains(target)) return;
setOpen(false);
}
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open, listboxId]);
const handleSelect = (itemId: number, name: string) => {
onChange(itemId);
setOpen(false);
setSearch(name);
};
const setActive = (index: number) => {
activeIndexRef.current = index;
setActiveIndex(index);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(0);
return;
}
const next =
activeIndexRef.current < items.length - 1
? activeIndexRef.current + 1
: 0;
setActive(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(items.length - 1);
return;
}
const prev =
activeIndexRef.current > 0
? activeIndexRef.current - 1
: items.length - 1;
setActive(prev);
} else if (e.key === "Enter") {
if (
open &&
activeIndexRef.current >= 0 &&
activeIndexRef.current < items.length
) {
e.preventDefault();
const item = items[activeIndexRef.current];
handleSelect(item.id, item.name);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
} else if (e.key === "Home") {
if (open && items.length > 0) {
e.preventDefault();
setActive(0);
}
} else if (e.key === "End") {
if (open && items.length > 0) {
e.preventDefault();
setActive(items.length - 1);
}
} else if (e.key === "Tab") {
setOpen(false);
}
};
const activeId =
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
return (
<div className="admin-form-group">
<div
ref={containerRef}
style={{ position: "relative" }}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-owns={listboxId}
>
<input
type="text"
className="admin-form-input"
placeholder="Hledat položku..."
value={search}
role="searchbox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeId}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 200)}
onFocus={() => {
setOpen(true);
updatePosition();
}}
onKeyDown={handleKeyDown}
/>
{open && items.length > 0 && (
<ul className="admin-item-picker-list">
{items.map((item) => (
<li
key={item.id}
className={`admin-item-picker-item ${value === item.id ? "active" : ""}`}
onClick={() => {
onChange(item.id);
setOpen(false);
setSearch(item.name);
}}
>
<span className="admin-item-picker-name">{item.name}</span>
{item.item_number && (
<span className="admin-item-picker-number">
{item.item_number}
</span>
)}
{item.available_quantity !== undefined && (
<span className="admin-item-picker-qty">
{item.available_quantity} {item.unit}
</span>
)}
</li>
))}
</ul>
)}
{open &&
items.length > 0 &&
dropdownStyle &&
createPortal(
<ul
id={listboxId}
role="listbox"
className="admin-item-picker-list"
style={dropdownStyle}
>
{items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={value === item.id}
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(item.id, item.name);
}}
>
<span className="admin-item-picker-name">{item.name}</span>
{item.item_number && (
<span className="admin-item-picker-number">
{item.item_number}
</span>
)}
{item.available_quantity !== undefined && (
<span className="admin-item-picker-qty">
{item.available_quantity} {item.unit}
</span>
)}
</li>
))}
</ul>,
document.body,
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useQuery } from "@tanstack/react-query";
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
interface ReservationPickerProps {
itemId: number | null;
projectId: number | null;
value: number | null;
onChange: (reservationId: number | null, remainingQty: number) => void;
}
export default function ReservationPicker({
itemId,
projectId,
value,
onChange,
}: ReservationPickerProps) {
const { data: result } = useQuery(
warehouseReservationListOptions({
item_id: itemId ?? undefined,
project_id: projectId ?? undefined,
status: "ACTIVE",
perPage: 100,
}),
);
const reservations = result?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val) {
onChange(null, 0);
return;
}
const reservationId = Number(val);
const reservation = reservations.find((r) => r.id === reservationId);
if (reservation) {
onChange(reservationId, Number(reservation.remaining_qty));
}
}}
>
<option value=""> žádná rezervace </option>
{reservations.map((r) => (
<option key={r.id} value={r.id}>
R{r.id} {r.project?.name ?? `Projekt #${r.project_id}`} zbývá:{" "}
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
</option>
))}
</select>
);
}

View File

@@ -1,9 +1,9 @@
import type { WarehouseLocation } from "../../lib/queries/warehouse";
import { ReactNode, useId, useRef } from "react";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementLine {
export interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
@@ -15,132 +15,233 @@ export interface MovementLine {
notes: string | null;
}
interface WarehouseMovementTableProps {
lines: MovementLine[];
onChange: (lines: MovementLine[]) => void;
mode: "receipt" | "issue";
locations: WarehouseLocation[];
export type MovementRowRenderer<T> = (
item: T,
index: number,
updateItem: (field: string, value: unknown) => void,
removeItem: () => void,
) => ReactNode;
interface WarehouseMovementTableProps<T extends { key: string }> {
items: T[];
onChange: (items: T[]) => void;
mode: "receipt" | "issue" | "inventory";
defaultItem?: () => Omit<T, "key">;
renderRow?: MovementRowRenderer<T>;
renderHeader?: () => ReactNode;
}
let lineCounter = 0;
function parseDecimal(raw: string): number {
// Strip leading minus if you want negative support; here we want only positive
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
export default function WarehouseMovementTable({
lines,
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
});
export default function WarehouseMovementTable<T extends { key: string }>({
items,
onChange,
mode,
}: WarehouseMovementTableProps) {
const addLine = () => {
onChange([
...lines,
{
key: `line-${++lineCounter}`,
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
]);
defaultItem,
renderRow,
renderHeader,
}: WarehouseMovementTableProps<T>) {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const addItem = () => {
const factory =
defaultItem ??
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
};
const removeLine = (index: number) => {
onChange(lines.filter((_, i) => i !== index));
const removeItem = (index: number) => {
onChange(items.filter((_, i) => i !== index));
};
const updateLine = (index: number, field: string, value: unknown) => {
const updated = [...lines];
const updateItem = (index: number, field: string, value: unknown) => {
const updated = [...items];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
// If a custom row renderer is supplied, the caller is fully in charge of
// the row layout (and typically the column headers too — see inventory mode).
if (renderRow) {
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
{renderHeader && (
<thead>
<tr>{renderHeader()}</tr>
</thead>
)}
<tbody>
{items.map((item, index) => (
<tr key={item.key}>
{renderRow(
item,
index,
(field, value) => updateItem(index, field, value),
() => removeItem(index),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
const movementItems = items as unknown as MovementItem[];
return (
<div className="admin-warehouse-movement-table">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
{mode === "issue" && <th>Šarže</th>}
<th>Množství</th>
<th>Cena/ks</th>
<th>Lokace</th>
<th>Poznámka</th>
<th></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={line.key}>
<td>
<ItemPicker
value={line.item_id}
onChange={(id) => updateLine(index, "item_id", id)}
/>
</td>
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
<thead>
<tr>
<th className="admin-warehouse-col-item">Položka</th>
{mode === "issue" && (
<td>
<BatchPicker
itemId={line.item_id}
value={line.batch_id}
onChange={(batchId, unitPrice) => {
updateLine(index, "batch_id", batchId);
updateLine(index, "unit_price", unitPrice);
}}
<th className="admin-warehouse-col-batch">Šarže</th>
)}
<th className="admin-warehouse-col-qty">Množství</th>
<th className="admin-warehouse-col-price">Cena/ks</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-notes">Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{movementItems.map((item, index) => (
<tr key={item.key}>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</td>
)}
<td>
<input
type="number"
className="admin-form-input"
value={line.quantity || ""}
onChange={(e) =>
updateLine(index, "quantity", Number(e.target.value))
}
min="0"
step="0.001"
/>
</td>
<td>
<input
type="number"
className="admin-form-input"
value={line.unit_price || ""}
onChange={(e) =>
updateLine(index, "unit_price", Number(e.target.value))
}
min="0"
step="0.01"
disabled={mode === "issue"}
/>
</td>
<td>
<LocationSelect
value={line.location_id}
onChange={(id) => updateLine(index, "location_id", id)}
/>
</td>
<td>
<input
type="text"
className="admin-form-input"
value={line.notes ?? ""}
onChange={(e) => updateLine(index, "notes", e.target.value)}
/>
</td>
<td>
<button
type="button"
className="admin-btn-danger-sm"
onClick={() => removeLine(index)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
<button type="button" className="admin-btn-secondary" onClick={addLine}>
{mode === "issue" && (
<td className="admin-warehouse-col-batch">
<BatchPicker
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
</td>
)}
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.quantity || ""}
onChange={(e) =>
updateItem(
index,
"quantity",
parseDecimal(e.target.value),
)
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Množství, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-price">
<input
type="number"
className="admin-form-input"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(
index,
"unit_price",
parseDecimal(e.target.value),
)
}
min="0"
step="0.01"
disabled={mode === "issue"}
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Cena za kus, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(id) => updateItem(index, "location_id", id)}
/>
</td>
<td className="admin-warehouse-col-notes">
<input
type="text"
className="admin-form-input"
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>