263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useParams } from "react-router-dom";
|
||
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||
import { topicByName } from "../../lib/topics";
|
||
import { useAppStore } from "../../store";
|
||
import { api } from "../../lib/api";
|
||
import { daysBetween } from "../../lib/formatters";
|
||
import { Header, PageLayout } from "../../components/layout/Header";
|
||
import { EmptyState } from "../../components/EmptyState";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||
import { normalize } from "../../lib/normalize";
|
||
import { PurchaseHistory } from "./PurchaseHistory";
|
||
|
||
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||
function cleanPhoneForTel(phone?: string | null): string {
|
||
if (!phone) return "";
|
||
const enDigits = phone
|
||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||
}
|
||
|
||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||
export function SupplierDetailPage() {
|
||
useStoreData();
|
||
const { id = "" } = useParams();
|
||
const suppliers = useSuppliers();
|
||
const quotes = useQuotes();
|
||
const materials = useMaterials();
|
||
const topics = useAppStore((s) => s.topics);
|
||
|
||
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
|
||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||
// متریالهای متصل واقعی (از اتصال تأمینکننده-متریال بکاند)
|
||
const [linked, setLinked] = useState<string[]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
// محاسبه میانگین زمان تحویل این تأمینکننده بر اساس خریدهای تحویلشده
|
||
const avgLeadTime = useMemo(() => {
|
||
if (!s?.id) return null;
|
||
const supQuotes = quotes.filter(
|
||
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||
);
|
||
const times = supQuotes
|
||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||
.filter((x): x is number => x != null);
|
||
|
||
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||
}, [quotes, s?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
let alive = true;
|
||
void api.supplierMaterials
|
||
.list(id)
|
||
.then((rows) => alive && setLinked(rows.map((r) => r.material_name).filter(Boolean)))
|
||
.catch(() => alive && setLinked([]));
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [id]);
|
||
|
||
// متریالهای تأمینشده (نام متریالها) برای پیشنهاد در کومبوکس
|
||
const materialOptions = useMemo(() => {
|
||
const set = new Set<string>();
|
||
materials.forEach((m) => m.name && set.add(m.name));
|
||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||
}, [materials]);
|
||
|
||
// دستهی این تأمینکننده — دسته فقط برچسبِ خودِ تأمینکننده است (v2.7)
|
||
const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
|
||
|
||
if (!s) {
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header title="اطلاعات تماس تأمینکننده" backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||
<PageLayout>
|
||
<EmptyState emoji="😕" text="تأمینکنندهای یافت نشد." />
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const topic = topicByName(s.topic, topics);
|
||
const topicName = topic ? topic.name : s.topic;
|
||
const firstCat = (s.categories || [])[0] || "";
|
||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||
|
||
const saveCategories = (names: string[]) => {
|
||
if (!id || busy) return;
|
||
setBusy(true);
|
||
void (async () => {
|
||
try {
|
||
for (const name of names) {
|
||
if (linked.some((x) => normalize(x) === normalize(name))) continue;
|
||
const mat = materials.find((m) => normalize(m.name) === normalize(name));
|
||
if (!mat) {
|
||
toast.error(`متریال «${name}» در کاتالوگ یافت نشد — ابتدا از ویزارد بسازید.`);
|
||
continue;
|
||
}
|
||
await api.supplierMaterials.create(id, mat.id);
|
||
}
|
||
const rows = await api.supplierMaterials.list(id);
|
||
setLinked(rows.map((r) => r.material_name).filter(Boolean));
|
||
toast.success("متریالها ذخیره شدند.");
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "ذخیرهی متریالها ناموفق بود");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const removeCat = (name: string) => {
|
||
if (!id || busy) return;
|
||
setBusy(true);
|
||
void (async () => {
|
||
try {
|
||
const mat = materials.find((m) => normalize(m.name) === normalize(name));
|
||
if (mat) await api.supplierMaterials.remove(id, mat.id);
|
||
const rows = await api.supplierMaterials.list(id);
|
||
setLinked(rows.map((r) => r.material_name).filter(Boolean));
|
||
toast.success("متریال حذف شد.");
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "حذف متریال ناموفق بود");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||
<PageLayout>
|
||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="text-xl font-extrabold">{s.name}</div>
|
||
<Badge variant="outline">{topicName}</Badge>
|
||
</div>
|
||
{avgLeadTime != null && (
|
||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
|
||
<Clock className="h-3.5 w-3.5 text-emerald-400" />
|
||
<span>{avgLeadTime} روز</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||
<span>
|
||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||
</span>
|
||
|
||
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
|
||
<div className="flex items-center">
|
||
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
|
||
<b className="me-1">تلفن:</b>
|
||
{s.phone ? (
|
||
<a
|
||
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
|
||
dir="ltr"
|
||
title="تماس مستقیم با تأمینکننده"
|
||
>
|
||
<span className="tabular-nums">{s.phone}</span>
|
||
</a>
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</div>
|
||
|
||
<span>
|
||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||
</span>
|
||
<span>
|
||
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
|
||
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-2">
|
||
<Link
|
||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
پیشنهاد جدید
|
||
</Link>
|
||
{firstCat ? (
|
||
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
||
‹ بازگشت به دسته
|
||
</Link>
|
||
) : (
|
||
<Link to="/suppliers" className="btn-line-file no-underline">
|
||
‹ بازگشت به دستهبندیها
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* متریالهای این تأمینکننده */}
|
||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||
<h4 className="mb-1 text-[13.5px] font-bold">
|
||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||
</h4>
|
||
<div className="mb-2 text-[11.5px] text-muted">
|
||
متریالهایی که این تأمینکننده عرضه میکند را اینجا مدیریت کنید.
|
||
</div>
|
||
{(linked || []).length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||
{linked.map((c) => (
|
||
<span key={c} className="spec-chip">
|
||
{c}
|
||
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<MultiSelectCombobox
|
||
values={[]}
|
||
onChange={(cats) => saveCategories(cats)}
|
||
options={materialOptions}
|
||
placeholder="افزودن متریال..."
|
||
/>
|
||
{purchased.length > 0 && (
|
||
<div className="mt-3 border-t border-line pt-2">
|
||
<div className="mb-1 text-[11px] font-semibold text-muted">متریالهای خریداریشده (از پیشنهادها):</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{purchased.map((c) => (
|
||
<span key={c} className="spec-chip">
|
||
{c}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* سابقه خرید از این تأمینکننده */}
|
||
<div className="mt-4 max-w-4xl">
|
||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||
</div>
|
||
|
||
<ConfirmDeleteDialog
|
||
open={pendingRemove !== null}
|
||
itemName={pendingRemove || ""}
|
||
onConfirm={() => {
|
||
if (pendingRemove !== null) removeCat(pendingRemove);
|
||
setPendingRemove(null);
|
||
}}
|
||
onCancel={() => setPendingRemove(null)}
|
||
/>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
} |