feat(warehouse): add reservations, inventory, reports, and dashboard pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
257
src/admin/pages/WarehouseInventoryForm.tsx
Normal file
257
src/admin/pages/WarehouseInventoryForm.tsx
Normal 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)}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user