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
250 lines
8.0 KiB
TypeScript
250 lines
8.0 KiB
TypeScript
import { ReactNode, useId, useRef } from "react";
|
|
import ItemPicker from "./ItemPicker";
|
|
import LocationSelect from "./LocationSelect";
|
|
import BatchPicker from "./BatchPicker";
|
|
|
|
export interface MovementItem {
|
|
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;
|
|
}
|
|
|
|
export type MovementRowRenderer<T> = (
|
|
item: T,
|
|
index: number,
|
|
updateItem: (field: string, value: unknown) => void,
|
|
removeItem: () => void,
|
|
) => ReactNode;
|
|
|
|
interface WarehouseMovementTableProps<T extends { key: string }> {
|
|
items: T[];
|
|
onChange: (items: T[]) => void;
|
|
mode: "receipt" | "issue" | "inventory";
|
|
defaultItem?: () => Omit<T, "key">;
|
|
renderRow?: MovementRowRenderer<T>;
|
|
renderHeader?: () => ReactNode;
|
|
}
|
|
|
|
function parseDecimal(raw: string): number {
|
|
// Strip leading minus if you want negative support; here we want only positive
|
|
const cleaned = raw.replace(/[eE+\-]/g, "");
|
|
const n = Number(cleaned);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
|
|
item_id: null,
|
|
quantity: 0,
|
|
unit_price: 0,
|
|
location_id: null,
|
|
batch_id: null,
|
|
reservation_id: null,
|
|
notes: null,
|
|
});
|
|
|
|
export default function WarehouseMovementTable<T extends { key: string }>({
|
|
items,
|
|
onChange,
|
|
mode,
|
|
defaultItem,
|
|
renderRow,
|
|
renderHeader,
|
|
}: WarehouseMovementTableProps<T>) {
|
|
const baseId = useId();
|
|
const counterRef = useRef(0);
|
|
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
|
|
|
const addItem = () => {
|
|
const factory =
|
|
defaultItem ??
|
|
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
|
|
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
|
|
};
|
|
const removeItem = (index: number) => {
|
|
onChange(items.filter((_, i) => i !== index));
|
|
};
|
|
const updateItem = (index: number, field: string, value: unknown) => {
|
|
const updated = [...items];
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
onChange(updated);
|
|
};
|
|
|
|
// If a custom row renderer is supplied, the caller is fully in charge of
|
|
// the row layout (and typically the column headers too — see inventory mode).
|
|
if (renderRow) {
|
|
return (
|
|
<div>
|
|
<div className="admin-warehouse-movement-table">
|
|
<table className="admin-table">
|
|
{renderHeader && (
|
|
<thead>
|
|
<tr>{renderHeader()}</tr>
|
|
</thead>
|
|
)}
|
|
<tbody>
|
|
{items.map((item, index) => (
|
|
<tr key={item.key}>
|
|
{renderRow(
|
|
item,
|
|
index,
|
|
(field, value) => updateItem(index, field, value),
|
|
() => removeItem(index),
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-secondary"
|
|
onClick={addItem}
|
|
>
|
|
+ Přidat řádek
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
|
|
const movementItems = items as unknown as MovementItem[];
|
|
|
|
return (
|
|
<div>
|
|
<div className="admin-warehouse-movement-table">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="admin-warehouse-col-item">Položka</th>
|
|
{mode === "issue" && (
|
|
<th className="admin-warehouse-col-batch">Šarže</th>
|
|
)}
|
|
<th className="admin-warehouse-col-qty">Množství</th>
|
|
<th className="admin-warehouse-col-price">Cena/ks</th>
|
|
<th className="admin-warehouse-col-location">Lokace</th>
|
|
<th className="admin-warehouse-col-notes">Poznámka</th>
|
|
<th>Akce</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{movementItems.map((item, index) => (
|
|
<tr key={item.key}>
|
|
<td className="admin-warehouse-col-item">
|
|
<ItemPicker
|
|
value={item.item_id}
|
|
itemName={item.item_name}
|
|
onChange={(id) => updateItem(index, "item_id", id)}
|
|
/>
|
|
</td>
|
|
{mode === "issue" && (
|
|
<td className="admin-warehouse-col-batch">
|
|
<BatchPicker
|
|
itemId={item.item_id}
|
|
value={item.batch_id}
|
|
onChange={(batchId, unitPrice) => {
|
|
updateItem(index, "batch_id", batchId);
|
|
updateItem(index, "unit_price", unitPrice);
|
|
}}
|
|
/>
|
|
</td>
|
|
)}
|
|
<td className="admin-warehouse-col-qty">
|
|
<input
|
|
type="number"
|
|
className="admin-form-input"
|
|
value={item.quantity || ""}
|
|
onChange={(e) =>
|
|
updateItem(
|
|
index,
|
|
"quantity",
|
|
parseDecimal(e.target.value),
|
|
)
|
|
}
|
|
min="0"
|
|
step="0.001"
|
|
inputMode="decimal"
|
|
pattern="[0-9]+([\.,][0-9]+)?"
|
|
aria-label={`Množství, řádek ${index + 1}`}
|
|
/>
|
|
</td>
|
|
<td className="admin-warehouse-col-price">
|
|
<input
|
|
type="number"
|
|
className="admin-form-input"
|
|
value={item.unit_price || ""}
|
|
onChange={(e) =>
|
|
updateItem(
|
|
index,
|
|
"unit_price",
|
|
parseDecimal(e.target.value),
|
|
)
|
|
}
|
|
min="0"
|
|
step="0.01"
|
|
disabled={mode === "issue"}
|
|
inputMode="decimal"
|
|
pattern="[0-9]+([\.,][0-9]+)?"
|
|
aria-label={`Cena za kus, řádek ${index + 1}`}
|
|
/>
|
|
</td>
|
|
<td className="admin-warehouse-col-location">
|
|
<LocationSelect
|
|
value={item.location_id}
|
|
onChange={(id) => updateItem(index, "location_id", id)}
|
|
/>
|
|
</td>
|
|
<td className="admin-warehouse-col-notes">
|
|
<input
|
|
type="text"
|
|
className="admin-form-input"
|
|
value={item.notes ?? ""}
|
|
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<div className="admin-table-actions">
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon danger"
|
|
onClick={() => removeItem(index)}
|
|
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"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-secondary"
|
|
onClick={addItem}
|
|
>
|
|
+ Přidat řádek
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|