diff --git a/frontend/src/components/MilestoneRow.tsx b/frontend/src/components/MilestoneRow.tsx index 9561b43..dfdbbcd 100644 --- a/frontend/src/components/MilestoneRow.tsx +++ b/frontend/src/components/MilestoneRow.tsx @@ -12,9 +12,9 @@ export interface MilestoneRowProps { // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { return ( -
- {/* کادر انتخاب تاریخ */} -
+
+ {/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */} +
onChange({ ...milestone, when_iso: iso })} @@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false } />
- {/* کادر ورود مبلغ و حروف زیر آن */} + {/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
void; placeholder?: string; className?: string; + inputClassName?: string; + align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین) showWords?: boolean; // نمایش معادل فارسی زیر input inputMode?: "numeric" | "decimal" | undefined; - maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد) + maxDecimals?: number; // حداکثر رقم اعشار disabled?: boolean; } @@ -19,6 +21,8 @@ export function NumberInput({ onChange, placeholder, className, + inputClassName, + align = "right", showWords = false, inputMode = "numeric", maxDecimals, @@ -36,8 +40,6 @@ export function NumberInput({ const [text, setText] = useState(() => (value != null ? formatValue(value) : "")); - // همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل). - // null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود. useEffect(() => { const cur = parseNum(text); if (value != null && value !== cur) { @@ -60,19 +62,28 @@ export function NumberInput({ const n = parseNum(text); + const alignClass = + align === "center" + ? "!text-center text-center" + : align === "left" + ? "!text-left text-left" + : "!text-right text-right"; + return ( -
+
{ const raw = e.target.value; - // فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ - // («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند const parsed = parseNum(raw); const trailingSep = /[.,،/٬٫]$/.test(raw); if (parsed != null && !trailingSep) { @@ -85,17 +96,12 @@ export function NumberInput({ }} onBlur={() => commit(text)} /> - {showWords && ( - - {n != null ? ( - <> - {groupCommas(n, true)} ({toWords(n)}) - - ) : ( - "" - )} - + {showWords && n != null && ( +
+ {groupCommas(n, true)}{" "} + ({toWords(n)}) +
)}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 04686b1..6b10617 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, @@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { const [selectedProjects, setSelectedProjects] = useState([]); const [notes, setNotes] = useState(""); const [invoices, setInvoices] = useState([]); + const [proformas, setProformas] = useState([]); const [itemRows, setItemRows] = useState([]); const [tsCards, setTsCards] = useState([]); const [quoteDate, setQuoteDate] = useState(null); @@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { return { key: li.id, origLi: li, - // «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛ - // دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست) cat: m.name || "", brand: m.brand || "", specs: materialSpecs(m), @@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { .list(quoteId, "invoice") .then((list) => alive && setInvoices(list)) .catch(() => alive && setInvoices([])); + void api.files + .list(quoteId, "proforma") + .then((list) => alive && setProformas(list)) + .catch(() => alive && setProformas([])); return () => { alive = false; }; }, [quoteId]); - if (!q || !ready) return null; + 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 +256,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 +271,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 () => { @@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { next.projects = [...selectedProjects]; next.notes = notes.trim(); next.invoices = invoices; + next.proformas = proformas; const tsIdMap: Record = {}; const newTermsheets: Termsheet[] = []; @@ -276,6 +330,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 +350,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 +376,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 +413,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 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { <> !o && onClose()}> - {/* هدر سفید و تمیز */} + {/* هدر */} ویرایش پیشنهاد خرید
@@ -421,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
)} - {/* تب‌بار سفید و استاندارد */} + {/* تب‌بار */}
- {/* محتوای مودال — پس‌زمینه کاملاً سفید */} + {/* محتوا */}
- {/* تب ۱: اقلام و مشخصات */} + {/* تب ۱: اقلام */} {activeTab === "items" && (
- {/* کارت تأمین‌کننده و پروژه */}
@@ -536,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
- {/* لیست اقلام */} + {/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
اقلام سفارش ({faNum(itemRows.length)}) @@ -573,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
-
-
- + {/* ردیف ۱: متریال و برند */} +
+
+ patchItem(row.key, { cat: e.target.value })} />
-
- +
+ patchItem(row.key, { brand: e.target.value })} @@ -597,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
-
-
- - patchItem(row.key, { specs })} - options={specOptions} - placeholder="مشخصه" - /> -
- -
- - patchItem(row.key, { unit: e.target.value })} - /> -
+ {/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */} +
+ + patchItem(row.key, { specs })} + options={specOptions} + placeholder="انتخاب مشخصات..." + className="min-w-0" + />
- {/* جعبه ۳ عددی مقادیر */} -
-
- - { - patchItem(row.key, { - qty, - total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total, - }); - }} - placeholder="تعداد" - /> + {/* بخش مقادیر و محاسبات مالی */} +
+ {/* ردیف ۳: تعداد + واحد سنجش */} +
+
+ + { + patchItem(row.key, { + qty, + total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total, + }); + }} + placeholder="مقدار" + className="!text-right text-right" + /> +
+ +
+ + patchItem(row.key, { unit: e.target.value })} + /> +
-
- + {/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */} +
+ { patchItem(row.key, { price, - total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total, + total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total, }); }} - placeholder="واحد" + placeholder="قیمت هر واحد" + showWords maxDecimals={2} + className="!text-right text-right" />
-
- + {/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */} +
+ 0 ? Math.round((t / row.qty) * 100) / 100 : row.price, }); }} - placeholder="جمع" + placeholder="قیمت کل قلم" + showWords + className="!text-right text-right" />
- {/* شرایط پرداخت برای این قلم */} -
- {row.rejected ? ( + {row.rejected && ( +
- ) : ( -
- شرایط پرداخت مرتبط: -
- {tsCards.map((card, cIdx) => { - const isSelected = row.tsDomIds.includes(card.domId || card.key); - return ( - - ); - })} -
-
- )} -
+
+ )}
))} @@ -741,11 +770,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)} + /> ))}
@@ -891,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)} />
+ + {/* پیش‌فاکتورها — در همه‌ی مراحل چرخه‌ی پیشنهاد قابل افزودن/ویرایش است */} +
+
پیش‌فاکتورها
+ api.files.upload(q.id, "proforma", f)} + onDelete={(a) => + a.id && + void api.files.remove(q.id, "proforma", a.id).catch((err) => + toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"), + ) + } + onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)} + loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)} + label="افزودن پیش‌فاکتور" + /> +
)}
- {/* فوتر سفید، محکم و چسبان */} + {/* فوتر */} {q.status === "offered" ? (
- {/* مودال تایید حذف سفید */} + {/* مودال تایید حذف */} !deleting && setShowDeleteConfirm(open)}> diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index 738d8df..249ae5b 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -35,7 +35,6 @@ function InfoCell({ label, children, className }: { label: string; children: Rea ); } -// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { const sup = supplierById(store.suppliers, q.supplier_id); const expired = isExpired(q); @@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit if (q.status === "offered") { acceptedCell = ( + + {/* دکمه فلش اکسپند در هدر */}
- {/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */} -
- {/* ۱. پروژه — تمام‌عرض در موبایل */} + {/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */} +
+ {/* ۱. پروژه */}
@@ -200,7 +233,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* ۲. اقلام — نمایش عمودی چندخطی */} + {/* ۲. اقلام */}
{activeItems.length > 0 ? ( @@ -221,6 +254,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit e.stopPropagation()} className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]" title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`} > @@ -230,6 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit ))} + {/* ۳. مجموع مبلغ */} {pc ? (
@@ -286,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* جزئیات تکمیلی بازشونده */} - {expanded && } + {/* دکمه تمام‌عرض توضیحات بیشتر */} + + + {/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */} + {expanded && ( +
e.stopPropagation()}> + +
+ )}
); } \ No newline at end of file diff --git a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx index c371c8a..6fe0d83 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"; @@ -14,6 +15,15 @@ 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(); @@ -29,6 +39,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; @@ -68,7 +91,6 @@ export function SupplierDetailPage() { const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`; const saveCategories = (names: string[]) => { - // v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال if (!id || busy) return; setBusy(true); void (async () => { @@ -116,21 +138,51 @@ export function SupplierDetailPage() {
-
-
{s.name}
- {topicName} +
+
+
{s.name}
+ {topicName} +
+ {avgLeadTime != null && ( + + + {avgLeadTime} روز + + )}
-
+ +
موقعیت: {s.location || "—"} - - تلفن: {s.phone || "—"} - + + {/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */} +
+ + تلفن: + {s.phone ? ( + + {s.phone} + + ) : ( + — + )} +
+ شخص رابط: {s.contact || "—"} + + میانگین زمان تحویل:{" "} + {avgLeadTime != null ? `${avgLeadTime} روز` : "—"} +
+
- {/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */} + {/* متریال‌های این تأمین‌کننده */}

متریال‌های تأمین‌شده @@ -191,7 +243,7 @@ export function SupplierDetailPage() { )}

- {/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */} + {/* سابقه خرید از این تأمین‌کننده */}
@@ -208,4 +260,4 @@ export function SupplierDetailPage() {
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Suppliers/supplierUtils.tsx b/frontend/src/pages/Suppliers/supplierUtils.tsx index d4fd689..cc4ef48 100644 --- a/frontend/src/pages/Suppliers/supplierUtils.tsx +++ b/frontend/src/pages/Suppliers/supplierUtils.tsx @@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types"; import { normalize } from "../../lib/normalize"; import { fmtNum } from "../../lib/formatters"; +// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و 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 suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { return suppliers.filter( (s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)), @@ -29,7 +38,11 @@ export function SupplierCard({ return (
- + {/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */} + {s.name}
@@ -49,17 +62,34 @@ export function SupplierCard({ )}
+
{s.location || "—"} - - {s.phone || "—"} - + + {/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */} + {s.phone ? ( + + + {s.phone} + + ) : ( + + — + + )} + {s.contact || "—"}
+ {materials.length > 0 && (
@@ -71,6 +101,7 @@ export function SupplierCard({
)} +
diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index 37a6567..1749b2f 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { Trash2, Plus } from "lucide-react"; import type { Material } from "../../lib/types"; import { normalize } from "../../lib/normalize"; import { useMaterials } from "../../hooks"; @@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox"; import { NumberInput } from "../../components/NumberInput"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { api, apiV26 } from "../../lib/api"; +import { faNum } from "../../lib/formatters"; export interface ItemDraft { tempId: string; @@ -51,7 +53,6 @@ export function materialOptions( apiBrands: string[] = [], apiSpecs: string[] = [], ): string[] { - // متریال‌ها سراسری هستند — مستقل از تاپیک let list = materials; const cat = item.cat.trim(); @@ -67,21 +68,17 @@ export function materialOptions( }; if (field === "brand") { - // برند سراسری است — مستقل از تاپیک و متریال const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); apiBrands.forEach((b) => b && allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); return [...allBrands].sort((a, b) => a.localeCompare(b, "fa")); } - // فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ - // هم‌نام پیشنهاد می‌شوند. + if (cat) list = list.filter( (m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat), ); - // مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی - // متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ - // فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند. + const base = new Set(); apiSpecs.forEach((s) => s && base.add(s)); materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s))); @@ -90,7 +87,7 @@ export function materialOptions( return [...base].sort((a, b) => a.localeCompare(b, "fa")); } -// مرحله ۲ — جدول پویای اقلام سفارش +// مرحله ۲ — مدیریت اقلام سفارش export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { const materials = useMaterials(); @@ -167,146 +164,205 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember if (!it.specs.length) return; rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs }; } - // rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس - // همان هویتِ متریال انجام می‌شود، نه دسته. const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs); if (dup) return; onRemember(rec); }; return ( -
-

- ۲ - اقلام سفارش -

-
قلم است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} /> - -
- متریال - برند - مشخصه - تعداد - واحد - قیمت واحد - قیمت کل +
+ {/* هدر */} +
+
+
+ + ۲ + +

+ اقلام سفارش +

+
+ + {faNum(items.length)} قلم + +
+

+ مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینه‌های جدید با تایپ ذخیره می‌شوند. +

-
- {items.map((it) => ( -
- patch(it.tempId, { cat: x.label })} - items={allMaterialNames} - allowAdd - addSuffix="به عنوان متریال جدید" - placeholder="متریال" - onInputChange={(v) => patch(it.tempId, { cat: v })} - onBlur={() => rememberField("i-cat", it)} - inputClassName="!text-[12px] !h-9" - className="min-w-0" - /> - patch(it.tempId, { brand: x.label })} - items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)} - allowAdd - addSuffix="به عنوان برند جدید" - placeholder="برند" - onInputChange={(v) => patch(it.tempId, { brand: v })} - onBlur={() => rememberField("i-brand", it)} - inputClassName="!text-[12px] !h-9" - className="min-w-0" - /> - patch(it.tempId, { specs })} - options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)} - placeholder="مشخصه" - className="min-w-0" - /> - { - patch(it.tempId, { - qty, - total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total, - }); - }} - placeholder="تعداد" - showWords - /> - patch(it.tempId, { unit: x.label })} - items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))} - allowAdd - addSuffix="به عنوان واحد جدید" - placeholder="واحد" - onInputChange={(v) => patch(it.tempId, { unit: v })} - onAdd={(v) => { - const name = v.trim(); - if (!name) return; - void apiV26.catalog - .createUnit(name) - .then(() => { - setUnitOptions((prev) => - prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name], - ); - }) - .catch(() => {}); - }} - inputClassName="!text-[12px] !h-9" - className="min-w-0" - /> - { - patch(it.tempId, { - price, - total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total, - }); - }} - placeholder="قیمت واحد" - showWords - maxDecimals={2} - /> - { - patch(it.tempId, { - total, - price: - total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price, - }); - }} - placeholder="قیمت کل" - showWords - /> - - {/* دکمه حذف مدرن با آیکون سطل زباله */} - + {/* لیست کارت‌های اقلام */} +
+ {items.map((it, idx) => ( +
+ {/* سربرگ قلم */} +
+
+ + {faNum(idx + 1)} + + + {it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""} + +
+ {items.length > 1 && ( + + )} +
+ + {/* ردیف ۱: متریال و برند */} +
+
+ + patch(it.tempId, { cat: x.label })} + items={allMaterialNames} + allowAdd + addSuffix="به عنوان متریال جدید" + placeholder="نام متریال" + onInputChange={(v) => patch(it.tempId, { cat: v })} + onBlur={() => rememberField("i-cat", it)} + inputClassName="!text-xs !h-9 text-right" + className="min-w-0" + /> +
+ +
+ + patch(it.tempId, { brand: x.label })} + items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)} + allowAdd + addSuffix="به عنوان برند جدید" + placeholder="برند" + onInputChange={(v) => patch(it.tempId, { brand: v })} + onBlur={() => rememberField("i-brand", it)} + inputClassName="!text-xs !h-9 text-right" + className="min-w-0" + /> +
+
+ + {/* ردیف ۲: مشخصه فنی */} +
+ + patch(it.tempId, { specs })} + options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)} + placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)" + className="min-w-0" + /> +
+ + {/* بخش مقادیر و محاسبات مالی */} +
+ {/* ردیف ۳: تعداد + واحد سنجش */} +
+
+ + { + patch(it.tempId, { + qty, + total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total, + }); + }} + placeholder="مقدار" + className="!text-right text-right" + /> +
+ +
+ + patch(it.tempId, { unit: x.label })} + items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))} + allowAdd + addSuffix="به عنوان واحد جدید" + placeholder="واحد (شاخه، متر، کیلو...)" + onInputChange={(v) => patch(it.tempId, { unit: v })} + onAdd={(v) => { + const name = v.trim(); + if (!name) return; + void apiV26.catalog + .createUnit(name) + .then(() => { + setUnitOptions((prev) => + prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name], + ); + }) + .catch(() => {}); + }} + inputClassName="!text-xs !h-9 text-right" + className="min-w-0" + /> +
+
+ + {/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */} +
+ + { + patch(it.tempId, { + price, + total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total, + }); + }} + placeholder="قیمت هر واحد" + showWords + className="!text-right text-right" + /> +
+ + {/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */} +
+ + { + patch(it.tempId, { + total, + price: + total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price, + }); + }} + placeholder="قیمت کل قلم" + showWords + className="!text-right text-right" + /> +
+
))}
- {/* دکمه افزودن قلم با استایل برجسته و خط‌چین */} + {/* دکمه افزودن قلم جدید */}
diff --git a/frontend/src/pages/Wizard/Stepper.tsx b/frontend/src/pages/Wizard/Stepper.tsx index 5928181..f1cd68f 100644 --- a/frontend/src/pages/Wizard/Stepper.tsx +++ b/frontend/src/pages/Wizard/Stepper.tsx @@ -9,43 +9,61 @@ export interface StepperProps { onGoto: (step: number) => void; } -const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"]; +const STEPS = [ + { id: 1, label: "تأمین‌کننده" }, + { id: 2, label: "اقلام" }, + { id: 3, label: "شرایط پرداخت" }, + { id: 4, label: "پروژه" }, + { id: 5, label: "بازبینی و ثبت" }, +]; -// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است -// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی +// نوار ۵ مرحله‌ای ویزارد — واکنش‌گرا و ضدشکستگی در موبایل export function Stepper({ current, completedSteps, onGoto }: StepperProps) { return ( -
- {STEPS.map((label, i) => { - const step = i + 1; +
+ {STEPS.map(({ id: step, label }) => { const isCurrent = step === current; const isComplete = completedSteps.has(step); + return ( ); })}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index 31f5ceb..8b56369 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -357,11 +357,9 @@ export function WizardPage() { goToStep(currentStep + 1); }; - const rememberMaterial = (_rec: MaterialRec) => { - // رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند - }; + const rememberMaterial = (_rec: MaterialRec) => {}; - // ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ---------- + // ---------- پروژه‌ها ---------- const projectNames = useMemo(() => { const names = new Set(projects.map((p) => p.name)); quotes.forEach((q) => { @@ -389,7 +387,7 @@ export function WizardPage() { }); }; - // ---------- خلاصه‌ی مرحله ۵ ---------- + // ---------- خلاصه مرحله ۵ ---------- const review = useMemo(() => { let grandTotal = 0; items.forEach((it) => { @@ -432,23 +430,14 @@ export function WizardPage() { const combined = `${field} ${msg}`.toLowerCase(); - // ۱. تأمین‌کننده if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" }; - - // ۲. اقلام / متریال / واحد / تعداد / قیمت if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) { return { step: 2, field: field || "line_items" }; } - - // ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) { return { step: 3, field: field || "termsheets" }; } - - // ۴. پروژه if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" }; - - // ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" }; if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" }; if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" }; @@ -521,10 +510,6 @@ export function WizardPage() { if (!cat && !brand && qty == null && price == null) continue; const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید"; - // هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛ - // دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با - // برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند - // تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند). let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName)); if (!material) { const matched = matchMaterial( @@ -678,7 +663,7 @@ export function WizardPage() { }; return ( -
+
#{loadedDraftId.slice(0, 8)}
)} + {/* نوار مراحل استپر */} + {/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */} {currentStep === 1 && ( -
+
{ @@ -724,12 +711,12 @@ export function WizardPage() {
)} {currentStep === 2 && ( -
+
)} {currentStep === 3 && ( -
+
)} {currentStep === 4 && ( -
+
)} {currentStep === 5 && ( -
+
)} - {/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */} -
- {/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */} + {/* پاورقی کنترل مراحل */} +
{currentStep > 1 && (
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */} -
+
- {/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */} {currentStep < TOTAL_STEPS && (
- {/* دیالوگ ذخیره با فیلدهای خالی */} + {/* دیالوگ فیلدهای خالی */} فیلدهای خالی -
- هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟ +
+ هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
{emptyFields.map((f) => (