From b176f0f5ff4fb505138ba042978df34ec17e94f1 Mon Sep 17 00:00:00 2001 From: Behrad Date: Mon, 21 Sep 2026 19:39:12 +0330 Subject: [PATCH] fix(wizard): optimize stepper and line items layout for mobile --- frontend/src/pages/Wizard/Step2LineItems.tsx | 314 +++++++++++-------- frontend/src/pages/Wizard/Stepper.tsx | 50 ++- frontend/src/pages/Wizard/WizardPage.tsx | 62 ++-- 3 files changed, 239 insertions(+), 187 deletions(-) diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index 37a6567..6a0a2b0 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,199 @@ 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" + 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" + 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, { + qty, + total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total, + }); + }} + placeholder="تعداد" + showWords + /> +
+ +
+ + { + 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 + /> +
+
))}
- {/* دکمه افزودن قلم با استایل برجسته و خط‌چین */} + {/* دکمه افزودن قلم جدید */}
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 46903bc..323b651 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -127,11 +127,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) => { @@ -159,7 +157,7 @@ export function WizardPage() { }); }; - // ---------- خلاصه‌ی مرحله ۵ ---------- + // ---------- خلاصه مرحله ۵ ---------- const review = useMemo(() => { let grandTotal = 0; items.forEach((it) => { @@ -202,23 +200,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" }; @@ -291,10 +280,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( @@ -426,7 +411,7 @@ export function WizardPage() { }; return ( -
+
+ {/* نوار مراحل استپر */} + {/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */} {currentStep === 1 && ( -
+
{ @@ -461,12 +448,12 @@ export function WizardPage() {
)} {currentStep === 2 && ( -
+
)} {currentStep === 3 && ( -
+
)} {currentStep === 4 && ( -
+
)} {currentStep === 5 && ( -
+
)} - {/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */} -
- {/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */} + {/* پاورقی کنترل مراحل */} +
{currentStep > 1 && (
- {/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */} -
+
انصراف - {/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */} {currentStep < TOTAL_STEPS && (
- {/* دیالوگ ذخیره با فیلدهای خالی */} + {/* دیالوگ فیلدهای خالی */} فیلدهای خالی -
- هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟ +
+ هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
{emptyFields.map((f) => (