chore(mui): delete 8 dead legacy components replaced by the kit
Removed (zero importers, build-verified): FormField, Pagination, AdminDatePicker, SortIcon, warehouse Supplier/Location/Batch selects, WarehouseMovementTable. FormModal + ConfirmModal kept (still used by OrderConfirmationModal + ProjectFileManager — to be migrated before full removal). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,233 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import DatePicker, { registerLocale } from "react-datepicker";
|
|
||||||
import { cs } from "date-fns/locale";
|
|
||||||
import { parse, format } from "date-fns";
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
|
||||||
|
|
||||||
registerLocale("cs", cs);
|
|
||||||
|
|
||||||
// Ensure portal root exists
|
|
||||||
if (
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
!document.getElementById("datepicker-portal")
|
|
||||||
) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.id = "datepicker-portal";
|
|
||||||
document.body.appendChild(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTouchDevice = () =>
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
|
||||||
|
|
||||||
interface CustomInputProps {
|
|
||||||
value?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
readOnly?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
|
||||||
(
|
|
||||||
{ value, onClick, onChange, placeholder, required, readOnly, disabled },
|
|
||||||
ref,
|
|
||||||
) => (
|
|
||||||
<input
|
|
||||||
className="admin-form-input"
|
|
||||||
onClick={onClick}
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
placeholder={placeholder}
|
|
||||||
ref={ref}
|
|
||||||
required={required}
|
|
||||||
readOnly={readOnly}
|
|
||||||
disabled={disabled}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface NativeInputProps {
|
|
||||||
mode: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
required?: boolean;
|
|
||||||
minDate?: string;
|
|
||||||
maxDate?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeToInputType: Record<string, string> = {
|
|
||||||
month: "month",
|
|
||||||
time: "time",
|
|
||||||
};
|
|
||||||
|
|
||||||
function NativeInput({
|
|
||||||
mode,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
required,
|
|
||||||
minDate,
|
|
||||||
maxDate,
|
|
||||||
disabled,
|
|
||||||
placeholder,
|
|
||||||
}: NativeInputProps) {
|
|
||||||
const type = modeToInputType[mode] || "date";
|
|
||||||
// For time inputs, min/max must be in HH:mm format, not date format
|
|
||||||
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
|
||||||
if (!val) return undefined;
|
|
||||||
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
|
||||||
// otherwise it's not a valid time min/max — return undefined
|
|
||||||
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
|
||||||
if (val.includes(":")) return val.substring(0, 5);
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
const minProp =
|
|
||||||
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
|
||||||
const maxProp =
|
|
||||||
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
lang="cs"
|
|
||||||
value={value || ""}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
disabled={disabled}
|
|
||||||
min={minProp}
|
|
||||||
max={maxProp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminDatePickerProps {
|
|
||||||
mode?: "date" | "month" | "time";
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
minDate?: string;
|
|
||||||
maxDate?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDatePicker({
|
|
||||||
mode = "date",
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
required,
|
|
||||||
minDate,
|
|
||||||
maxDate,
|
|
||||||
disabled,
|
|
||||||
placeholder,
|
|
||||||
}: AdminDatePickerProps) {
|
|
||||||
const useNative = isTouchDevice();
|
|
||||||
|
|
||||||
// Mode-aware default placeholder so empty date fields always show a format
|
|
||||||
// hint (callers can still override, e.g. "Od"/"Do" on range filters).
|
|
||||||
const effectivePlaceholder =
|
|
||||||
placeholder ??
|
|
||||||
(mode === "time" ? "hh:mm" : mode === "month" ? "mm.rrrr" : "dd.mm.rrrr");
|
|
||||||
|
|
||||||
if (useNative) {
|
|
||||||
return (
|
|
||||||
<NativeInput
|
|
||||||
mode={mode}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
required={required}
|
|
||||||
minDate={minDate}
|
|
||||||
maxDate={maxDate}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={effectivePlaceholder}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toDate = (val: string | null | undefined): Date | null => {
|
|
||||||
if (!val) return null;
|
|
||||||
try {
|
|
||||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
|
||||||
if (mode === "time") {
|
|
||||||
const [h, m] = val.split(":");
|
|
||||||
const d = new Date();
|
|
||||||
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (date: Date | null) => {
|
|
||||||
if (!date) {
|
|
||||||
onChange("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === "date") onChange(format(date, "yyyy-MM-dd"));
|
|
||||||
else if (mode === "time") onChange(format(date, "HH:mm"));
|
|
||||||
else if (mode === "month") onChange(format(date, "yyyy-MM"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseMinMax = (val: string | undefined): Date | undefined => {
|
|
||||||
if (!val) return undefined;
|
|
||||||
try {
|
|
||||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
|
||||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const customInput = (
|
|
||||||
<CustomInput
|
|
||||||
required={required}
|
|
||||||
placeholder={effectivePlaceholder}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
selected: toDate(value),
|
|
||||||
onChange: handleChange,
|
|
||||||
locale: "cs",
|
|
||||||
customInput,
|
|
||||||
placeholderText: effectivePlaceholder,
|
|
||||||
minDate: parseMinMax(minDate),
|
|
||||||
maxDate: parseMinMax(maxDate),
|
|
||||||
popperPlacement: "bottom-start" as const,
|
|
||||||
portalId: "datepicker-portal",
|
|
||||||
disabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (mode === "time") {
|
|
||||||
return (
|
|
||||||
<DatePicker
|
|
||||||
{...commonProps}
|
|
||||||
showTimeSelect
|
|
||||||
showTimeSelectOnly
|
|
||||||
timeIntervals={5}
|
|
||||||
timeCaption="Čas"
|
|
||||||
dateFormat="HH:mm"
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "month") {
|
|
||||||
return (
|
|
||||||
<DatePicker {...commonProps} showMonthYearPicker dateFormat="MM/yyyy" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DatePicker {...commonProps} dateFormat="dd.MM.yyyy" />;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import {
|
|
||||||
type CSSProperties,
|
|
||||||
type ReactNode,
|
|
||||||
isValidElement,
|
|
||||||
cloneElement,
|
|
||||||
useId,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
interface FormFieldProps {
|
|
||||||
label: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
error?: string;
|
|
||||||
required?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FormField({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
error,
|
|
||||||
required,
|
|
||||||
style,
|
|
||||||
}: FormFieldProps) {
|
|
||||||
const generatedId = useId();
|
|
||||||
const childProps = isValidElement(children)
|
|
||||||
? (children.props as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const childId = childProps?.id ? String(childProps.id) : generatedId;
|
|
||||||
const childWithId = isValidElement(children)
|
|
||||||
? cloneElement(children, { id: childId } as React.Attributes)
|
|
||||||
: children;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-form-group" style={style}>
|
|
||||||
<label className="admin-form-label" htmlFor={childId}>
|
|
||||||
{label}
|
|
||||||
{required && <span className="admin-form-required"> *</span>}
|
|
||||||
</label>
|
|
||||||
{childWithId}
|
|
||||||
{error && <span className="admin-form-error">{error}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
interface PaginationProps {
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
per_page: number;
|
|
||||||
total_pages: number;
|
|
||||||
} | null;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onPerPageChange?: (perPage: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Pagination({
|
|
||||||
pagination,
|
|
||||||
onPageChange,
|
|
||||||
onPerPageChange,
|
|
||||||
}: PaginationProps) {
|
|
||||||
if (!pagination || pagination.total_pages <= 1) return null;
|
|
||||||
|
|
||||||
const { page, total_pages, total } = pagination;
|
|
||||||
|
|
||||||
const getPages = () => {
|
|
||||||
const pages: (number | string)[] = [];
|
|
||||||
const delta = 2;
|
|
||||||
for (let i = 1; i <= total_pages; i++) {
|
|
||||||
if (
|
|
||||||
i === 1 ||
|
|
||||||
i === total_pages ||
|
|
||||||
(i >= page - delta && i <= page + delta)
|
|
||||||
) {
|
|
||||||
pages.push(i);
|
|
||||||
} else if (pages[pages.length - 1] !== "...") {
|
|
||||||
pages.push("...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pages;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="admin-pagination"
|
|
||||||
role="navigation"
|
|
||||||
aria-label="Stránkování"
|
|
||||||
>
|
|
||||||
<div className="admin-pagination-info">{total} záznamů</div>
|
|
||||||
<div className="admin-pagination-controls">
|
|
||||||
<button
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => onPageChange(page - 1)}
|
|
||||||
className="admin-pagination-page"
|
|
||||||
aria-label="Předchozí stránka"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{getPages().map((p, i) =>
|
|
||||||
typeof p === "string" ? (
|
|
||||||
<span key={`dots-${i}`} className="admin-pagination-ellipsis">
|
|
||||||
...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => onPageChange(p)}
|
|
||||||
className={`admin-pagination-page ${p === page ? "active" : ""}`}
|
|
||||||
aria-label={`Stránka ${p}`}
|
|
||||||
aria-current={p === page ? "page" : undefined}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
disabled={page >= total_pages}
|
|
||||||
onClick={() => onPageChange(page + 1)}
|
|
||||||
className="admin-pagination-page"
|
|
||||||
aria-label="Další stránka"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M9 18l6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{onPerPageChange && (
|
|
||||||
<select
|
|
||||||
value={pagination.per_page}
|
|
||||||
onChange={(e) => onPerPageChange(Number(e.target.value))}
|
|
||||||
className="admin-pagination-select"
|
|
||||||
>
|
|
||||||
{[10, 25, 50, 100].map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{n} / stránka
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
interface SortIconProps {
|
|
||||||
column: string;
|
|
||||||
sort: string | null;
|
|
||||||
order: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SortIcon({ column, sort, order }: SortIconProps) {
|
|
||||||
if (sort !== column) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ opacity: 0.3, marginLeft: 4 }}
|
|
||||||
>
|
|
||||||
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ marginLeft: 4 }}
|
|
||||||
>
|
|
||||||
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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 BatchesResponse {
|
|
||||||
batches: Batch[];
|
|
||||||
total_stock: number;
|
|
||||||
reserved_quantity: number;
|
|
||||||
available_quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BatchPickerProps {
|
|
||||||
itemId: number | null;
|
|
||||||
value: number | null;
|
|
||||||
onChange: (batchId: number, unitPrice: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BatchPicker({
|
|
||||||
itemId,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: BatchPickerProps) {
|
|
||||||
const { data: response } = useQuery({
|
|
||||||
queryKey: ["warehouse", "batches", itemId],
|
|
||||||
queryFn: () =>
|
|
||||||
jsonQuery<BatchesResponse>(
|
|
||||||
`/api/admin/warehouse/items/${itemId}/batches`,
|
|
||||||
),
|
|
||||||
enabled: !!itemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const batches = response?.batches ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{response && response.reserved_quantity > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
|
|
||||||
{response.reserved_quantity} ks)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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)} Kč
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user