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:
@@ -49,7 +49,7 @@ import useDocumentDraft, { readDraft } from "../hooks/useDocumentDraft";
|
||||
import { DRAFT_KEYS } from "../lib/draftKeys";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { invoiceDetailOptions } from "../lib/queries/invoices";
|
||||
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
import { bankAccountsOptions } from "../lib/queries/common";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
RichTextView,
|
||||
CustomerPicker,
|
||||
headerActionsSx,
|
||||
} from "../ui";
|
||||
import {
|
||||
@@ -606,9 +607,6 @@ export default function InvoiceDetail() {
|
||||
const [invoiceNumber, setInvoiceNumber] = useState("");
|
||||
const initialSnapshotRef = useRef<string | null>(null);
|
||||
|
||||
const [customerSearch, setCustomerSearch] = useState("");
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
|
||||
const companySettings = useQuery(companySettingsOptions()).data;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -900,26 +898,6 @@ export default function InvoiceDetail() {
|
||||
return addDaysLocalStr(dueDays, form.issue_date);
|
||||
}, [form.issue_date, dueDays]);
|
||||
|
||||
// ─── Create mode: customer filtering ───
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!customerSearch) return customers;
|
||||
const q = customerSearch.toLowerCase();
|
||||
return customers.filter(
|
||||
(c) =>
|
||||
(c.name || "").toLowerCase().includes(q) ||
|
||||
(c.company_id || "").includes(customerSearch) ||
|
||||
(c.city || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [customers, customerSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowCustomerDropdown(false);
|
||||
if (showCustomerDropdown) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
}, [showCustomerDropdown]);
|
||||
|
||||
const selectBankAccount = (accountId: string) => {
|
||||
const acc = bankAccounts.find((a) => a.id === Number(accountId));
|
||||
if (acc) {
|
||||
@@ -943,15 +921,14 @@ export default function InvoiceDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectCustomer = (customer: Customer) => {
|
||||
const selectCustomer = (id: number | null) => {
|
||||
const customer = id != null ? customers.find((c) => c.id === id) : null;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: customer.id,
|
||||
customer_name: customer.name,
|
||||
customer_id: id,
|
||||
customer_name: customer?.name ?? "",
|
||||
}));
|
||||
setErrors((prev) => ({ ...prev, customer_id: "" }));
|
||||
setCustomerSearch("");
|
||||
setShowCustomerDropdown(false);
|
||||
};
|
||||
|
||||
// ─── Create mode: items management ───
|
||||
@@ -1823,115 +1800,13 @@ export default function InvoiceDetail() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odběratel" 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>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: null,
|
||||
customer_name: "",
|
||||
}))
|
||||
}
|
||||
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 (název, IČ, město)..."
|
||||
autoComplete="off"
|
||||
/>
|
||||
{showCustomerDropdown && (
|
||||
<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, 10).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.company_id || c.city) && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: "0.8rem",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{c.company_id && `IČ: ${c.company_id}`}
|
||||
{c.city && ` · ${c.city}`}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<TextField
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
ConfirmDialog,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
CustomerPicker,
|
||||
headerActionsSx,
|
||||
} from "../ui";
|
||||
import {
|
||||
@@ -690,12 +691,11 @@ export default function IssuedOrderDetail() {
|
||||
});
|
||||
};
|
||||
|
||||
const selectCustomer = (val: string) => {
|
||||
const cid = val ? Number(val) : null;
|
||||
const c = customers.find((x: Customer) => x.id === cid);
|
||||
const selectCustomer = (id: number | null) => {
|
||||
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: cid,
|
||||
customer_id: id,
|
||||
customer_name: c?.name || "",
|
||||
}));
|
||||
setErrors((prev) => ({ ...prev, customer_id: "" }));
|
||||
@@ -956,19 +956,13 @@ export default function IssuedOrderDetail() {
|
||||
}}
|
||||
>
|
||||
<Field label="Dodavatel" error={errors.customer_id} required>
|
||||
<Select
|
||||
value={form.customer_id ? String(form.customer_id) : ""}
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
disabled={!editable}
|
||||
options={[
|
||||
{ value: "", label: "— Vyberte dodavatele —" },
|
||||
...customers.map((c: Customer) => ({
|
||||
value: String(c.id),
|
||||
label: c.company_id
|
||||
? `${c.name} (${c.company_id})`
|
||||
: c.name,
|
||||
})),
|
||||
]}
|
||||
error={errors.customer_id}
|
||||
placeholder="Vyberte dodavatele…"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum objednávky" error={errors.order_date} required>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
90
src/admin/ui/CustomerPicker.tsx
Normal file
90
src/admin/ui/CustomerPicker.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
|
||||
import MuiTextField from "@mui/material/TextField";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { Customer } from "../lib/queries/offers";
|
||||
|
||||
interface CustomerPickerProps {
|
||||
customers: Customer[];
|
||||
/** Selected customer id (FK), or null when none chosen. */
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When set, draws the input in its error state (red border). The error TEXT
|
||||
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
|
||||
* render helperText here to avoid duplicating the message.
|
||||
*/
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
/** Optional id forwarded by <Field> for label association. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Search matches the customer name AND the IČO (company_id), so typing either
|
||||
// finds the record. Match on a stringified "name + company_id" so MUI's default
|
||||
// case-insensitive substring filtering covers both.
|
||||
const filterCustomers = createFilterOptions<Customer>({
|
||||
stringify: (c) => `${c.name} ${c.company_id ?? ""}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared searchable customer picker — a controlled MUI Autocomplete over the
|
||||
* customers list, bound to the customer id. Used by Offers, Invoices and Issued
|
||||
* Orders so all three FK customer selectors behave identically. Renders bare
|
||||
* (no label/error text); wrap it in the page's <Field label error> for the
|
||||
* label + error line.
|
||||
*/
|
||||
export default function CustomerPicker({
|
||||
customers,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
placeholder,
|
||||
id,
|
||||
}: CustomerPickerProps) {
|
||||
const selected = customers.find((c) => c.id === value) ?? null;
|
||||
return (
|
||||
<Autocomplete<Customer>
|
||||
options={customers}
|
||||
value={selected}
|
||||
onChange={(_, opt) => onChange(opt ? opt.id : null)}
|
||||
getOptionLabel={(c) => c?.name ?? ""}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
filterOptions={filterCustomers}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoHighlight
|
||||
renderOption={(props, c) => {
|
||||
const { key, ...rest } = props as typeof props & { key: string };
|
||||
return (
|
||||
<Box component="li" key={key} {...rest}>
|
||||
<Box>
|
||||
{c.name}
|
||||
{(c.company_id || c.city) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: "block", color: "text.secondary" }}
|
||||
>
|
||||
{c.company_id && `IČ: ${c.company_id}`}
|
||||
{c.company_id && c.city && " · "}
|
||||
{c.city}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
id={id}
|
||||
placeholder={placeholder ?? "Vyberte zákazníka…"}
|
||||
error={!!error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type { DataColumn } from "./DataTable";
|
||||
export { Field, SwitchField } from "./Field";
|
||||
export { default as Select } from "./Select";
|
||||
export type { SelectOption } from "./Select";
|
||||
export { default as CustomerPicker } from "./CustomerPicker";
|
||||
export { default as StatusChip } from "./StatusChip";
|
||||
export { CheckboxField } from "./Checkbox";
|
||||
export { default as Alert } from "./Alert";
|
||||
|
||||
Reference in New Issue
Block a user