Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import Autocomplete from "@mui/material/Autocomplete";
|
|
import TextField from "@mui/material/TextField";
|
|
import CircularProgress from "@mui/material/CircularProgress";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
import useDebounce from "../../hooks/useDebounce";
|
|
import {
|
|
warehouseItemListOptions,
|
|
type WarehouseItem,
|
|
} from "../../lib/queries/warehouse";
|
|
|
|
interface ItemPickerProps {
|
|
value: number | null;
|
|
onChange: (itemId: number) => void;
|
|
itemName?: string;
|
|
}
|
|
|
|
/** Minimal option shape used to display a selected value that isn't in the
|
|
* current search results (edit/prefill via `itemName`). */
|
|
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
|
|
|
|
export default function ItemPicker({
|
|
value,
|
|
onChange,
|
|
itemName,
|
|
}: ItemPickerProps) {
|
|
const [inputValue, setInputValue] = useState(itemName ?? "");
|
|
// Debounce the search so the list query doesn't fire on every keystroke.
|
|
const debouncedSearch = useDebounce(inputValue, 300);
|
|
|
|
const { data, isFetching } = useQuery(
|
|
warehouseItemListOptions({ search: debouncedSearch, perPage: 20 }),
|
|
);
|
|
const options: ItemOption[] = data?.data ?? [];
|
|
|
|
// Show the selected value even when it isn't in the current search results
|
|
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
|
|
const selectedOption: ItemOption | null =
|
|
value == null
|
|
? null
|
|
: (options.find((o) => o.id === value) ?? {
|
|
id: value,
|
|
name: itemName ?? "",
|
|
});
|
|
|
|
return (
|
|
<Autocomplete<ItemOption>
|
|
value={selectedOption}
|
|
inputValue={inputValue}
|
|
onInputChange={(_, newInput) => setInputValue(newInput)}
|
|
onChange={(_, opt) => {
|
|
if (opt) onChange(opt.id);
|
|
}}
|
|
options={options}
|
|
loading={isFetching}
|
|
filterOptions={(x) => x}
|
|
getOptionLabel={(o) => o.name}
|
|
isOptionEqualToValue={(o, v) => o.id === v.id}
|
|
noOptionsText="Žádné položky"
|
|
renderOption={(props, option) => {
|
|
const { key, ...rest } = props;
|
|
return (
|
|
<Box component="li" key={key} {...rest}>
|
|
<Box>
|
|
<Typography variant="body2">{option.name}</Typography>
|
|
{(option.item_number ||
|
|
option.available_quantity !== undefined) && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{option.item_number}
|
|
{option.item_number && option.available_quantity !== undefined
|
|
? " · "
|
|
: ""}
|
|
{option.available_quantity !== undefined
|
|
? `${option.available_quantity} ${option.unit ?? ""}`
|
|
: ""}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}}
|
|
renderInput={(params) => (
|
|
<TextField
|
|
{...params}
|
|
size="small"
|
|
placeholder="Hledat položku..."
|
|
slotProps={{
|
|
input: {
|
|
...params.InputProps,
|
|
endAdornment: (
|
|
<>
|
|
{isFetching ? (
|
|
<CircularProgress color="inherit" size={18} />
|
|
) : null}
|
|
{params.InputProps.endAdornment}
|
|
</>
|
|
),
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|