feat(warehouse): add reservations, inventory, reports, and dashboard pages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-29 14:36:31 +02:00
parent fdc7037e60
commit f389c4b3cf
6 changed files with 2240 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
import { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import ItemPicker from "../components/warehouse/ItemPicker";
import LocationSelect from "../components/warehouse/LocationSelect";
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
import type { WarehouseLocation } from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
interface InventoryLine {
key: string;
item_id: number | null;
location_id: number | null;
actual_qty: number;
}
let lineCounter = 0;
export default function WarehouseInventoryForm() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [lines, setLines] = useState<InventoryLine[]>([]);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const { data: locations = [] } = useQuery(warehouseLocationListOptions());
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) {
alert.error("Přidejte alespoň jednu položku");
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
try {
const payload = {
notes: notes.trim() || null,
lines: lines
.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í");
} finally {
setSaving(false);
}
};
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
to="/warehouse/inventory"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">Nová inventura</h1>
</div>
</div>
<div className="admin-page-actions">
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? "Ukládání..." : "Vytvořit inventuru"}
</button>
</div>
</motion.div>
{/* Notes */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<FormField label="Poznámky">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky k inventuře"
/>
</FormField>
</div>
</motion.div>
{/* Lines */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
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)}
>
&times;
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn-secondary"
onClick={addEmptyLine}
style={{ marginTop: "0.75rem" }}
>
+ Přidat řádek
</button>
</div>
</motion.div>
</div>
);
}