Files
ERP/frontend/src/pages/Suppliers/SupplierDetailPage.tsx
T

263 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}