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:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user