Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
8.8 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|