From 9386d4846fb60873d0554639efa8e66093b1fdef Mon Sep 17 00:00:00 2001 From: Behrad Date: Mon, 21 Sep 2026 19:26:52 +0330 Subject: [PATCH] feat: add lead time badge, refine mobile supplier click, and integrate termsheet cards in edit modal --- frontend/src/pages/Dashboard/EditModal.tsx | 219 ++++++++---------- .../pages/Suppliers/SupplierDetailPage.tsx | 38 ++- .../src/pages/Suppliers/supplierUtils.tsx | 6 +- 3 files changed, 129 insertions(+), 134 deletions(-) diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 04686b1..60282fe 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { NumberInput } from "../../components/NumberInput"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { FileList } from "../../components/FileList"; -import { MilestoneRow } from "../../components/MilestoneRow"; +import { TermsheetCard } from "../../components/TermsheetCard"; import { Button } from "../../components/ui/button"; import { Dialog, @@ -153,8 +153,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { return { key: li.id, origLi: li, - // «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛ - // دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست) cat: m.name || "", brand: m.brand || "", specs: materialSpecs(m), @@ -196,22 +194,59 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { }; }, [quoteId]); - if (!q || !ready) return null; + // لیست اقلام برای ارسال به TermsheetCard (دقیقاً مشابه ویزارد) + const itemOptions = useMemo(() => { + return itemRows + .filter((r) => !r.rejected || r.restore) + .map((r, idx) => ({ + tempId: r.key, + label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`, + })); + }, [itemRows]); - const tsLabel = (card: EditTsCard, idx: number) => { - const orig = q.termsheets.find((t) => t.id === card.domId); - return orig?.label || `گزینه ${faNum(idx + 1)}`; + // محاسبه اقلام تحت پوشش هر کارت شرایط پرداخت + const getCoveredIds = (card: EditTsCard) => { + const cardId = card.domId || card.key; + const explicit = itemRows + .filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId)) + .map((r) => r.key); + + if (!explicit.length && tsCards[0]?.key === card.key) { + return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key); + } + return explicit; }; + // تغییر اقلام تحت پوشش یک کارت شرایط پرداخت + const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => { + const card = tsCards.find((c) => c.key === cardKey); + const cardId = card?.domId || cardKey; + setItemRows((rows) => + rows.map((row) => { + const shouldHave = newCoveredKeys.includes(row.key); + const exists = row.tsDomIds.includes(cardId); + if (shouldHave === exists) return row; + const nextTsDomIds = shouldHave + ? [...row.tsDomIds, cardId] + : row.tsDomIds.filter((id) => id !== cardId); + return { ...row, tsDomIds: nextTsDomIds }; + }), + ); + }; + + if (!q || !ready) return null; + const patchItem = (key: string, patch: Partial) => { setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); }; const addItemRow = () => { + const newKey = "new-" + Math.random().toString(36).slice(2, 9); + const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : ""; setItemRows((rows) => [ ...rows, { - key: "new-" + Math.random().toString(36).slice(2, 9), + key: newKey, cat: "", brand: "", specs: [], @@ -219,7 +254,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { unit: "", price: null, total: null, - tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [], + tsDomIds: defaultTsId ? [defaultTsId] : [], acceptedTsId: null, rejected: false, restore: true, @@ -234,12 +269,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { const addTsCard = () => { setTsCards((cards) => [ ...cards, - { key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, + { + key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6), + domId: "", + milestones: [{ when_iso: null, amount: null }], + }, ]); }; const removeTsCard = (key: string) => { + if (tsCards.length <= 1) { + toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد."); + return; + } setTsCards((cards) => cards.filter((c) => c.key !== key)); + const card = tsCards.find((c) => c.key === key); + const cardId = card?.domId || key; + setItemRows((rows) => + rows.map((r) => ({ + ...r, + tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key), + })), + ); }; const confirm = async () => { @@ -276,6 +327,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { ? domId : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); tsIdMap[domId] = realId; + tsIdMap[card.key] = realId; const orig = next.termsheets.find((t) => t.id === realId); const label = orig?.label || `گزینه ${faNum(i + 1)}`; const st = orig?.status || "offered"; @@ -295,7 +347,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { const total = row.total; if (!cat && !brand && qty == null && price == null) continue; - // هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد) let material = matchMaterial(materialsCopy, cat, brand, specs); let materialId: string; if (material) { @@ -322,7 +373,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { if (!rejected) { row.tsDomIds.forEach((domId) => { const realId = tsIdMap[domId] || domId; - if (realId) termsheet_ids.push(realId); + if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId); }); if (!termsheet_ids.length && next.termsheets.length) { termsheet_ids.push(next.termsheets[0].id); @@ -359,7 +410,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { const referencedTs = new Set(); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); - next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); + next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0); next.line_items.forEach((li) => { if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { li.accepted_ts_id = null; @@ -406,7 +457,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { <> !o && onClose()}> - {/* هدر سفید و تمیز */} + {/* هدر */} ویرایش پیشنهاد خرید
@@ -421,7 +472,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
)} - {/* تب‌بار سفید و استاندارد */} + {/* تب‌بار */}
- {/* محتوای مودال — پس‌زمینه کاملاً سفید */} + {/* محتوا */}
- {/* تب ۱: اقلام و مشخصات */} + {/* تب ۱: اقلام */} {activeTab === "items" && (
- {/* کارت تأمین‌کننده و پروژه */}
@@ -536,7 +586,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
- {/* لیست اقلام */} + {/* لیست اقلام — بدون چک‌باکس‌های شلوغ شرایط پرداخت */}
اقلام سفارش ({faNum(itemRows.length)}) @@ -621,7 +671,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
- {/* جعبه ۳ عددی مقادیر */}
@@ -671,9 +720,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
- {/* شرایط پرداخت برای این قلم */} -
- {row.rejected ? ( + {row.rejected && ( +
- ) : ( -
- شرایط پرداخت مرتبط: -
- {tsCards.map((card, cIdx) => { - const isSelected = row.tsDomIds.includes(card.domId || card.key); - return ( - - ); - })} -
-
- )} -
+
+ )}
))} @@ -741,11 +753,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
)} - {/* تب ۲: شرایط پرداخت */} + {/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */} {activeTab === "terms" && (
-
گزینه‌های شرایط پرداخت
+
+
گزینه‌های شرایط پرداخت
+
+ روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید. +
+
{!hasReceipts && ( - )} -
- -
- {card.milestones.map((ms, i) => ( - - updateTs(card.key, { - milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)), - }) - } - onRemove={() => - updateTs(card.key, { - milestones: card.milestones.filter((_, j) => j !== i), - }) - } - /> - ))} -
- - {!hasReceipts && ( - - )} -
+ index={idx + 1} + milestones={card.milestones} + onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })} + items={itemOptions} + coveredIds={getCoveredIds(card)} + onCoverageChange={(ids) => handleCoverageChange(card.key, ids)} + onRemove={() => removeTsCard(card.key)} + /> ))} @@ -895,7 +858,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { )} - {/* فوتر سفید، محکم و چسبان */} + {/* فوتر */} {q.status === "offered" ? (
- {/* مودال تایید حذف سفید */} + {/* مودال تایید حذف */} !deleting && setShowDeleteConfirm(open)}> diff --git a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx index c371c8a..eb4944c 100644 --- a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx +++ b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { MapPin, Phone, User, Plus, Package, X } from "lucide-react"; +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"; @@ -29,6 +30,19 @@ export function SupplierDetailPage() { const [linked, setLinked] = useState([]); 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; @@ -116,10 +130,19 @@ export function SupplierDetailPage() {
-
-
{s.name}
- {topicName} +
+
+
{s.name}
+ {topicName} +
+ {avgLeadTime != null && ( + + + {avgLeadTime} روز + + )}
+
موقعیت: {s.location || "—"} @@ -130,7 +153,12 @@ export function SupplierDetailPage() { شخص رابط: {s.contact || "—"} + + میانگین زمان تحویل:{" "} + {avgLeadTime != null ? `${avgLeadTime} روز` : "—"} +
+
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Suppliers/supplierUtils.tsx b/frontend/src/pages/Suppliers/supplierUtils.tsx index d4fd689..e9776bd 100644 --- a/frontend/src/pages/Suppliers/supplierUtils.tsx +++ b/frontend/src/pages/Suppliers/supplierUtils.tsx @@ -29,7 +29,11 @@ export function SupplierCard({ return (
- + {/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */} + {s.name}