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
229 lines
6.4 KiB
TypeScript
229 lines
6.4 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
|
|
|
|
interface ItemPickerProps {
|
|
value: number | null;
|
|
onChange: (itemId: number) => void;
|
|
itemName?: string;
|
|
}
|
|
|
|
export default function ItemPicker({
|
|
value,
|
|
onChange,
|
|
itemName,
|
|
}: ItemPickerProps) {
|
|
const [search, setSearch] = useState(itemName ?? "");
|
|
const [open, setOpen] = useState(false);
|
|
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
|
const items = data?.data ?? [];
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const activeIndexRef = useRef<number>(-1);
|
|
const listboxId = useId();
|
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
|
|
const [dropdownStyle, setDropdownStyle] = useState<{
|
|
position: "fixed";
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
zIndex: number;
|
|
} | null>(null);
|
|
|
|
const updatePosition = useCallback(() => {
|
|
if (!containerRef.current) return;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
setDropdownStyle({
|
|
position: "fixed",
|
|
top: rect.bottom + 2,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
zIndex: 100,
|
|
});
|
|
}, []);
|
|
|
|
// Reset active index when items change
|
|
useEffect(() => {
|
|
if (activeIndex >= items.length) {
|
|
activeIndexRef.current = -1;
|
|
setActiveIndex(-1);
|
|
}
|
|
}, [items, activeIndex]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
updatePosition();
|
|
const onScroll = () => updatePosition();
|
|
const onClose = () => setOpen(false);
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", onClose);
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", onClose);
|
|
};
|
|
} else {
|
|
setDropdownStyle(null);
|
|
activeIndexRef.current = -1;
|
|
setActiveIndex(-1);
|
|
}
|
|
}, [open, updatePosition]);
|
|
|
|
// Close on click-outside via mousedown on document
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDocMouseDown = (e: MouseEvent) => {
|
|
const target = e.target;
|
|
if (containerRef.current && target instanceof Node) {
|
|
if (!containerRef.current.contains(target)) {
|
|
// Don't close if click landed on an option in the portal
|
|
const listEl = document.getElementById(listboxId);
|
|
if (listEl && listEl.contains(target)) return;
|
|
setOpen(false);
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", onDocMouseDown);
|
|
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
}, [open, listboxId]);
|
|
|
|
const handleSelect = (itemId: number, name: string) => {
|
|
onChange(itemId);
|
|
setOpen(false);
|
|
setSearch(name);
|
|
};
|
|
|
|
const setActive = (index: number) => {
|
|
activeIndexRef.current = index;
|
|
setActiveIndex(index);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
if (!open) {
|
|
setOpen(true);
|
|
if (items.length > 0) setActive(0);
|
|
return;
|
|
}
|
|
const next =
|
|
activeIndexRef.current < items.length - 1
|
|
? activeIndexRef.current + 1
|
|
: 0;
|
|
setActive(next);
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
if (!open) {
|
|
setOpen(true);
|
|
if (items.length > 0) setActive(items.length - 1);
|
|
return;
|
|
}
|
|
const prev =
|
|
activeIndexRef.current > 0
|
|
? activeIndexRef.current - 1
|
|
: items.length - 1;
|
|
setActive(prev);
|
|
} else if (e.key === "Enter") {
|
|
if (
|
|
open &&
|
|
activeIndexRef.current >= 0 &&
|
|
activeIndexRef.current < items.length
|
|
) {
|
|
e.preventDefault();
|
|
const item = items[activeIndexRef.current];
|
|
handleSelect(item.id, item.name);
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
if (open) {
|
|
e.preventDefault();
|
|
setOpen(false);
|
|
}
|
|
} else if (e.key === "Home") {
|
|
if (open && items.length > 0) {
|
|
e.preventDefault();
|
|
setActive(0);
|
|
}
|
|
} else if (e.key === "End") {
|
|
if (open && items.length > 0) {
|
|
e.preventDefault();
|
|
setActive(items.length - 1);
|
|
}
|
|
} else if (e.key === "Tab") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
const activeId =
|
|
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{ position: "relative" }}
|
|
role="combobox"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-owns={listboxId}
|
|
>
|
|
<input
|
|
type="text"
|
|
className="admin-form-input"
|
|
placeholder="Hledat položku..."
|
|
value={search}
|
|
role="searchbox"
|
|
aria-autocomplete="list"
|
|
aria-controls={listboxId}
|
|
aria-activedescendant={activeId}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
setOpen(true);
|
|
updatePosition();
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
{open &&
|
|
items.length > 0 &&
|
|
dropdownStyle &&
|
|
createPortal(
|
|
<ul
|
|
id={listboxId}
|
|
role="listbox"
|
|
className="admin-item-picker-list"
|
|
style={dropdownStyle}
|
|
>
|
|
{items.map((item, index) => (
|
|
<li
|
|
key={item.id}
|
|
id={`${listboxId}-option-${index}`}
|
|
role="option"
|
|
aria-selected={value === item.id}
|
|
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
handleSelect(item.id, 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>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|