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

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate, Link } 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";
@@ -8,27 +8,31 @@ import { motion } from "framer-motion";
import FormField from "../components/FormField";
import ItemPicker from "../components/warehouse/ItemPicker";
import LocationSelect from "../components/warehouse/LocationSelect";
import WarehouseMovementTable from "../components/warehouse/WarehouseMovementTable";
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
import type { WarehouseLocation } from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
interface InventoryLine {
interface InventoryItem {
key: string;
item_id: number | null;
location_id: number | null;
actual_qty: number;
}
let lineCounter = 0;
const defaultInventoryItem = () => ({
item_id: null,
location_id: null,
actual_qty: 0,
});
export default function WarehouseInventoryForm() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [lines, setLines] = useState<InventoryLine[]>([]);
const [items, setItems] = useState<InventoryItem[]>([]);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
@@ -36,77 +40,51 @@ export default function WarehouseInventoryForm() {
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const addEmptyLine = () => {
setLines((prev) => [
...prev,
{
key: `line-${++lineCounter}`,
item_id: null,
location_id: null,
actual_qty: 0,
},
]);
};
// Start with one empty line
if (lines.length === 0) {
addEmptyLine();
}
const removeLine = (index: number) => {
setLines((prev) => prev.filter((_, i) => i !== index));
};
const updateLine = (index: number, field: string, value: unknown) => {
setLines((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const validate = (): boolean => {
const validLines = lines.filter((l) => l.item_id !== null);
if (validLines.length === 0) {
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;
items: {
item_id: number;
location_id: number | null;
actual_qty: number;
}[];
},
{ id?: number }
>({
url: () => "/api/admin/warehouse/inventory-sessions",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: (data) => {
alert.success("Inventura byla vytvořena");
navigate(`/warehouse/inventory/${data?.id}`);
},
});
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
try {
const payload = {
await createMutation.mutateAsync({
notes: notes.trim() || null,
lines: lines
items: items
.filter((l) => l.item_id !== null)
.map((l) => ({
item_id: l.item_id!,
location_id: l.location_id,
actual_qty: l.actual_qty,
})),
};
const response = await apiFetch(
"/api/admin/warehouse/inventory-sessions",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "inventory"] });
alert.success("Inventura byla vytvořena");
navigate(`/warehouse/inventory/${result.data?.id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit inventuru");
}
} catch {
alert.error("Chyba připojení");
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -184,72 +162,77 @@ export default function WarehouseInventoryForm() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky inventury</h3>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Lokace</th>
<th>Skutečné množství</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>
<td>
<LocationSelect
value={line.location_id}
onChange={(locId) =>
updateLine(index, "location_id", locId)
}
/>
</td>
<td>
<input
type="number"
className="admin-form-input"
value={line.actual_qty || ""}
onChange={(e) =>
updateLine(
index,
"actual_qty",
Number(e.target.value),
)
}
min="0"
step="0.001"
/>
</td>
<td>
<button
type="button"
className="admin-btn-danger-sm"
onClick={() => removeLine(index)}
<h3 className="admin-card-title">Položky</h3>
<WarehouseMovementTable<InventoryItem>
items={items}
onChange={setItems}
mode="inventory"
defaultItem={defaultInventoryItem}
renderHeader={() => (
<>
<th className="admin-warehouse-col-item">Položka</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-qty">Skutečné množství</th>
<th>Akce</th>
</>
)}
renderRow={(item, _index, updateItem, removeItem) => (
<>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
onChange={(id) => updateItem("item_id", id)}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(locId) => updateItem("location_id", locId)}
/>
</td>
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.actual_qty || ""}
onChange={(e) =>
updateItem("actual_qty", Number(e.target.value))
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Skutečné množství, řádek ${_index + 1}`}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={removeItem}
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"
>
&times;
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn-secondary"
onClick={addEmptyLine}
style={{ marginTop: "0.75rem" }}
>
+ Přidat řádek
</button>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</>
)}
/>
</div>
</motion.div>
</div>