feat(warehouse): add shared warehouse components

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-29 14:20:42 +02:00
parent 7b7c6bde68
commit 86bebf9776
5 changed files with 319 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}
export default function BatchPicker({
itemId,
value,
onChange,
}: BatchPickerProps) {
const { data: batches } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<Batch[]>(`/api/admin/warehouse/items/${itemId}/batches`),
enabled: !!itemId,
});
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>
);
}

View File

@@ -0,0 +1,59 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
interface ItemPickerProps {
value: number | null;
onChange: (itemId: number) => void;
}
export default function ItemPicker({ value, onChange }: ItemPickerProps) {
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
return (
<div className="admin-form-group">
<input
type="text"
className="admin-form-input"
placeholder="Hledat položku..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 200)}
/>
{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>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../../lib/queries/warehouse";
interface LocationSelectProps {
value: number | null;
onChange: (locationId: number | null) => void;
}
export default function LocationSelect({
value,
onChange,
}: LocationSelectProps) {
const { data: locations } = useQuery(warehouseLocationListOptions());
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez lokace --</option>
{locations?.map((loc: WarehouseLocation) => (
<option key={loc.id} value={loc.id}>
{loc.code} - {loc.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../../lib/queries/warehouse";
interface SupplierSelectProps {
value: number | null;
onChange: (supplierId: number | null) => void;
}
export default function SupplierSelect({
value,
onChange,
}: SupplierSelectProps) {
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
const suppliers = data?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez dodavatele --</option>
{suppliers.map((s: WarehouseSupplier) => (
<option key={s.id} value={s.id}>
{s.name}
{s.ico ? ` (${s.ico})` : ""}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,148 @@
import type { WarehouseLocation } from "../../lib/queries/warehouse";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementLine {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
interface WarehouseMovementTableProps {
lines: MovementLine[];
onChange: (lines: MovementLine[]) => void;
mode: "receipt" | "issue";
locations: WarehouseLocation[];
}
let lineCounter = 0;
export default function WarehouseMovementTable({
lines,
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,
},
]);
};
const removeLine = (index: number) => {
onChange(lines.filter((_, i) => i !== index));
};
const updateLine = (index: number, field: string, value: unknown) => {
const updated = [...lines];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
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>
{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);
}}
/>
</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}>
+ Přidat řádek
</button>
</div>
);
}