TorbatYar/frontend/components/accounting/EntityCombobox.tsx
Mortezakoohjani 7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30

280 lines
8.8 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, Search, X } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ds/Button";
export type ComboboxOption = {
id: string;
label: string;
subLabel?: string;
meta?: Record<string, string>;
};
function useDebounced(value: string, ms = 250) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export function EntityCombobox({
valueLabel,
placeholder,
options,
loading,
onQueryChange,
onSelect,
onClear,
emptyText = "موردی یافت نشد",
className,
}: {
valueLabel?: string;
placeholder: string;
options: ComboboxOption[];
loading?: boolean;
onQueryChange: (q: string) => void;
onSelect: (opt: ComboboxOption) => void;
onClear?: () => void;
emptyText?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
return (
<div ref={rootRef} className={cn("relative", className)}>
<div className="flex h-10 items-center gap-1 rounded-xl border border-[var(--border)] bg-[var(--surface)] px-2">
<Search className="h-4 w-4 shrink-0 text-[var(--muted)]" />
<input
className="h-full w-full bg-transparent text-sm text-secondary outline-none placeholder:text-[var(--muted)]"
placeholder={valueLabel || placeholder}
value={open ? q : valueLabel || q}
onFocus={() => {
setOpen(true);
onQueryChange(q);
}}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
onQueryChange(e.target.value);
}}
/>
{loading ? <Loader2 className="h-4 w-4 animate-spin text-[var(--muted)]" /> : null}
{valueLabel && onClear ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => {
setQ("");
onClear();
onQueryChange("");
}}
aria-label="پاک کردن"
>
<X className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{open ? (
<div className="absolute z-[120] mt-1 max-h-56 w-full overflow-auto rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-lg)]">
{options.length === 0 && !loading ? (
<p className="px-3 py-3 text-sm text-[var(--muted)]">{emptyText}</p>
) : (
options.map((opt) => (
<button
key={opt.id}
type="button"
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-right hover:bg-[var(--surface-muted)]"
onClick={() => {
onSelect(opt);
setQ("");
setOpen(false);
}}
>
<span className="text-sm font-medium text-secondary">{opt.label}</span>
{opt.subLabel ? <span className="text-xs text-[var(--muted)]">{opt.subLabel}</span> : null}
</button>
))
)}
</div>
) : null}
</div>
);
}
export function PartyCombobox({
module,
valueName,
onChange,
placeholder,
}: {
/** sales → customers, purchase → suppliers, else both */
module?: string;
valueName?: string;
onChange: (party: { id: string; name: string; kind: "customer" | "supplier" } | null) => void;
placeholder?: string;
}) {
const { tenantId } = useTenantId();
const [q, setQ] = useState("");
const dq = useDebounced(q);
const kind: "customer" | "supplier" | "both" =
module === "sales" ? "customer" : module === "purchase" ? "supplier" : "both";
const customersQ = useQuery({
queryKey: ["accounting", tenantId, "parties", "customers"],
queryFn: () => accountingApi.customers.list(tenantId!),
enabled: !!tenantId && (kind === "customer" || kind === "both"),
staleTime: 30_000,
});
const suppliersQ = useQuery({
queryKey: ["accounting", tenantId, "parties", "suppliers"],
queryFn: () => accountingApi.suppliers.list(tenantId!),
enabled: !!tenantId && (kind === "supplier" || kind === "both"),
staleTime: 30_000,
});
const options = useMemo(() => {
const needle = dq.trim().toLowerCase();
const rows: ComboboxOption[] = [];
if (kind === "customer" || kind === "both") {
for (const c of customersQ.data ?? []) {
const label = `${c.code}${c.name}`;
if (!needle || label.toLowerCase().includes(needle) || c.name.toLowerCase().includes(needle)) {
rows.push({
id: `customer:${c.id}`,
label: c.name,
subLabel: `مشتری · ${c.code} · مانده ${formatMoney(c.outstanding_balance)}`,
meta: { kind: "customer", id: c.id, name: c.name },
});
}
}
}
if (kind === "supplier" || kind === "both") {
for (const s of suppliersQ.data ?? []) {
const label = `${s.code}${s.name}`;
if (!needle || label.toLowerCase().includes(needle) || s.name.toLowerCase().includes(needle)) {
rows.push({
id: `supplier:${s.id}`,
label: s.name,
subLabel: `تأمین‌کننده · ${s.code} · مانده ${formatMoney(s.outstanding_balance)}`,
meta: { kind: "supplier", id: s.id, name: s.name },
});
}
}
}
return rows.slice(0, 40);
}, [customersQ.data, suppliersQ.data, dq, kind]);
const loading = customersQ.isFetching || suppliersQ.isFetching;
return (
<EntityCombobox
valueLabel={valueName}
placeholder={placeholder || "جستجوی طرف حساب…"}
options={options}
loading={loading}
onQueryChange={setQ}
emptyText="طرف حسابی یافت نشد — ابتدا در مشتریان/تأمین‌کنندگان ثبت کنید"
onSelect={(opt) => {
const kind = (opt.meta?.kind || "customer") as "customer" | "supplier";
onChange({ id: opt.meta!.id, name: opt.meta!.name, kind });
}}
onClear={() => onChange(null)}
/>
);
}
export function ItemCombobox({
valueLabel,
onSelect,
onClear,
}: {
valueLabel?: string;
onSelect: (item: {
id: string;
code: string;
name: string;
unit: string;
unit_cost: string;
}) => void;
onClear?: () => void;
}) {
const { tenantId } = useTenantId();
const [q, setQ] = useState("");
const dq = useDebounced(q);
const itemsQ = useQuery({
queryKey: ["accounting", tenantId, "ops-items"],
queryFn: () => accountingApi.ops.listItems(tenantId!),
enabled: !!tenantId,
staleTime: 30_000,
});
const options = useMemo(() => {
const needle = dq.trim().toLowerCase();
return (itemsQ.data ?? [])
.filter((i) => i.is_active !== false)
.filter((i) => {
if (!needle) return true;
return (
i.name.toLowerCase().includes(needle) ||
i.code.toLowerCase().includes(needle)
);
})
.slice(0, 40)
.map((i) => ({
id: i.id,
label: `${i.code}${i.name}`,
subLabel: `${i.unit} · موجودی ${i.quantity_on_hand} · فی ${formatMoney(i.unit_cost)}`,
meta: {
id: i.id,
code: i.code,
name: i.name,
unit: i.unit,
unit_cost: String(i.unit_cost ?? "0"),
},
}));
}, [itemsQ.data, dq]);
return (
<EntityCombobox
valueLabel={valueLabel}
placeholder="جستجوی کالا / خدمت…"
options={options}
loading={itemsQ.isFetching}
onQueryChange={setQ}
emptyText="کالایی یافت نشد — از کارت کالا ثبت کنید"
onSelect={(opt) =>
onSelect({
id: opt.meta!.id,
code: opt.meta!.code,
name: opt.meta!.name,
unit: opt.meta!.unit,
unit_cost: opt.meta!.unit_cost,
})
}
onClear={onClear}
/>
);
}