refactor(ui): shared searchable CustomerPicker across offers/invoices/issued-orders

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 16:42:24 +02:00
parent 20177e8f87
commit e868ecf769
5 changed files with 132 additions and 286 deletions

View File

@@ -75,6 +75,7 @@ import {
EmptyState,
LoadingState,
PageEnter,
CustomerPicker,
headerActionsSx,
} from "../ui";
import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
@@ -541,10 +542,9 @@ export default function OfferDetail() {
// ---- TanStack Query hooks ----
const offerQuery = useQuery(offerDetailOptions(id));
const { data: customersData } = useQuery({
...offerCustomersOptions(),
enabled: !isEdit,
});
// Load customers in both modes: the shared CustomerPicker needs the full list
// (even in edit/read-only mode) to resolve the current customer id → its row.
const { data: customersData } = useQuery(offerCustomersOptions());
const { data: templatesData } = useQuery(scopeTemplatesOptions());
const { data: itemTemplates } = useQuery(itemTemplatesOptions());
const { data: nextNumberData } = useQuery({
@@ -604,8 +604,6 @@ export default function OfferDetail() {
}
return [];
});
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
@@ -803,15 +801,6 @@ export default function OfferDetail() {
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false);
if (showCustomerDropdown) {
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [showCustomerDropdown]);
// Auto-save draft to localStorage (create mode only), record-scoped to the
// "new offer" form (recordId: null) via the shared useDocumentDraft hook.
const draftData = useMemo<OfferDraftData>(
@@ -842,15 +831,14 @@ export default function OfferDetail() {
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
const selectCustomer = (c: Customer) => {
setForm((prev) => ({ ...prev, customer_id: c.id, customer_name: c.name }));
const selectCustomer = (id: number | null) => {
const c = id != null ? customers.find((x) => x.id === id) : null;
setForm((prev) => ({
...prev,
customer_id: id,
customer_name: c?.name ?? "",
}));
setErrors((prev) => ({ ...prev, customer_id: undefined }));
setCustomerSearch("");
setShowCustomerDropdown(false);
};
const clearCustomer = () => {
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
};
const updateItem = (
@@ -880,12 +868,6 @@ export default function OfferDetail() {
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
const total = subtotal + vatAmount;
const filteredCustomers = customerSearch
? customers.filter((c) =>
c.name.toLowerCase().includes(customerSearch.toLowerCase()),
)
: customers;
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný";
@@ -1273,110 +1255,14 @@ export default function OfferDetail() {
/>
</Field>
<Field label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1.5,
py: 1,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Box component="span" sx={{ flex: 1 }}>
{form.customer_name}
</Box>
{!readOnly && (
<IconButton
size="small"
onClick={clearCustomer}
title="Odebrat zákazníka"
aria-label="Odebrat zákazníka"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</IconButton>
)}
</Box>
) : (
<Box
sx={{ position: "relative" }}
onClick={(e) => e.stopPropagation()}
>
<TextField
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
placeholder="Hledat zákazníka..."
InputProps={{ readOnly }}
/>
{showCustomerDropdown && !isInvalidated && (
<Box
sx={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
zIndex: 20,
mt: 0.5,
maxHeight: 280,
overflowY: "auto",
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 1,
boxShadow: 3,
}}
>
{filteredCustomers.length === 0 ? (
<Box sx={{ px: 1.5, py: 1, color: "text.secondary" }}>
Žádní zákazníci
</Box>
) : (
filteredCustomers.slice(0, 20).map((c) => (
<Box
key={c.id}
onMouseDown={() => selectCustomer(c)}
sx={{
px: 1.5,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<Box>{c.name}</Box>
{c.city && (
<Box
sx={{
fontSize: "0.8rem",
color: "text.secondary",
}}
>
{c.city}
</Box>
)}
</Box>
))
)}
</Box>
)}
</Box>
)}
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
disabled={readOnly}
error={errors.customer_id}
placeholder="Hledat zákazníka..."
/>
</Field>
</Box>