From 49dd159fb1b04f4247720310da95d6c4aaf6fb33 Mon Sep 17 00:00:00 2001 From: Behrad Date: Tue, 22 Sep 2026 23:06:17 +0330 Subject: [PATCH] feat: improve line items layout, number inputs and supplier click-to-call --- frontend/src/components/MilestoneRow.tsx | 8 +- frontend/src/components/NumberInput.tsx | 42 +++-- frontend/src/pages/Dashboard/EditModal.tsx | 119 ++++++------ frontend/src/pages/Dashboard/QuoteCard.tsx | 87 ++++++--- .../pages/Suppliers/SupplierDetailPage.tsx | 38 +++- .../src/pages/Suppliers/supplierUtils.tsx | 33 +++- frontend/src/pages/Wizard/Step2LineItems.tsx | 170 +++++++++--------- 7 files changed, 300 insertions(+), 197 deletions(-) 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 60282fe..1d536c3 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -194,7 +194,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { }; }, [quoteId]); - // لیست اقلام برای ارسال به TermsheetCard (دقیقاً مشابه ویزارد) const itemOptions = useMemo(() => { return itemRows .filter((r) => !r.rejected || r.restore) @@ -204,7 +203,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { })); }, [itemRows]); - // محاسبه اقلام تحت پوشش هر کارت شرایط پرداخت const getCoveredIds = (card: EditTsCard) => { const cardId = card.domId || card.key; const explicit = itemRows @@ -217,7 +215,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { return explicit; }; - // تغییر اقلام تحت پوشش یک کارت شرایط پرداخت const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => { const card = tsCards.find((c) => c.key === cardKey); const cardId = card?.domId || cardKey; @@ -586,7 +583,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
- {/* لیست اقلام — بدون چک‌باکس‌های شلوغ شرایط پرداخت */} + {/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
اقلام سفارش ({faNum(itemRows.length)}) @@ -623,23 +620,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
-
-
- + {/* ردیف ۱: متریال و برند */} +
+
+ patchItem(row.key, { cat: e.target.value })} />
-
- +
+ patchItem(row.key, { brand: e.target.value })} @@ -647,64 +645,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" />
diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index d3c1ae5..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 = ( - {/* دکمه فلش اکسپند در هدر (همیشه در موبایل و دسکتاپ قابل رویت است) */} + {/* دکمه فلش اکسپند در هدر */}
- {/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */} -
- {/* ۱. پروژه — تمام‌عرض در موبایل */} + {/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */} +
+ {/* ۱. پروژه */}
@@ -209,7 +233,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* ۲. اقلام — نمایش عمودی چندخطی */} + {/* ۲. اقلام */}
{activeItems.length > 0 ? ( @@ -230,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)}» را در داشبورد واحدها ثبت کنید`} > @@ -296,7 +321,7 @@ 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 eb4944c..6fe0d83 100644 --- a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx +++ b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx @@ -15,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(); @@ -82,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 () => { @@ -143,13 +151,29 @@ export function SupplierDetailPage() { )}
-
+
موقعیت: {s.location || "—"} - - تلفن: {s.phone || "—"} - + + {/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */} +
+ + تلفن: + {s.phone ? ( + + {s.phone} + + ) : ( + — + )} +
+ شخص رابط: {s.contact || "—"} @@ -179,7 +203,7 @@ export function SupplierDetailPage() {
- {/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */} + {/* متریال‌های این تأمین‌کننده */}

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

- {/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */} + {/* سابقه خرید از این تأمین‌کننده */}
diff --git a/frontend/src/pages/Suppliers/supplierUtils.tsx b/frontend/src/pages/Suppliers/supplierUtils.tsx index e9776bd..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)), @@ -53,17 +62,34 @@ export function SupplierCard({ )}
+
{s.location || "—"} - - {s.phone || "—"} - + + {/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */} + {s.phone ? ( + + + {s.phone} + + ) : ( + + — + + )} + {s.contact || "—"}
+ {materials.length > 0 && (
@@ -75,6 +101,7 @@ export function SupplierCard({
)} +
diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index 6a0a2b0..1749b2f 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -171,22 +171,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember return (
- {/* هدر تمیز و خوانا */} + {/* هدر */}
۲ -

+

اقلام سفارش

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

+

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

@@ -196,22 +196,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember {items.map((it, idx) => (
- {/* هدر قلم با شماره و نام و دکمه حذف */} -
+ {/* سربرگ قلم */} +
- + {faNum(idx + 1)} - + {it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
{items.length > 1 && (
- {/* ورودی‌های متریال و برند */} -
-
- + {/* ردیف ۱: متریال و برند */} +
+
+ patch(it.tempId, { cat: x.label })} @@ -234,13 +234,13 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember placeholder="نام متریال" onInputChange={(v) => patch(it.tempId, { cat: v })} onBlur={() => rememberField("i-cat", it)} - inputClassName="!text-xs !h-9" + inputClassName="!text-xs !h-9 text-right" className="min-w-0" />
-
- +
+ patch(it.tempId, { brand: x.label })} @@ -250,99 +250,105 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember placeholder="برند" onInputChange={(v) => patch(it.tempId, { brand: v })} onBlur={() => rememberField("i-brand", it)} - inputClassName="!text-xs !h-9" + 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, { 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" - 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, { + 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 * 100) / 100 : it.total, + total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total, }); }} - placeholder="واحد" + placeholder="قیمت هر واحد" showWords - maxDecimals={2} + className="!text-right text-right" />
-
- + {/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */} +
+ { patch(it.tempId, { total, price: - total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price, + total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price, }); }} - placeholder="جمع" + placeholder="قیمت کل قلم" showWords + className="!text-right text-right" />
@@ -353,7 +359,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember {/* دکمه افزودن قلم جدید */}