fix(wizard): optimize stepper and line items layout for mobile
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export interface ItemDraft {
|
export interface ItemDraft {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
@@ -51,7 +53,6 @@ export function materialOptions(
|
|||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
// متریالها سراسری هستند — مستقل از تاپیک
|
|
||||||
let list = materials;
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
@@ -67,21 +68,17 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و متریال
|
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
|
||||||
// همنام پیشنهاد میشوند.
|
|
||||||
if (cat)
|
if (cat)
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||||
);
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
|
||||||
const base = new Set<string>();
|
const base = new Set<string>();
|
||||||
apiSpecs.forEach((s) => s && base.add(s));
|
apiSpecs.forEach((s) => s && base.add(s));
|
||||||
materials.forEach((m) => (m.specs || []).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"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
// مرحله ۲ — مدیریت اقلام سفارش
|
||||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
|
|
||||||
@@ -167,46 +164,83 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
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);
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
{/* هدر تمیز و خوانا */}
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||||
|
۲
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-gray-900">
|
||||||
اقلام سفارش
|
اقلام سفارش
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
</div>
|
||||||
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-700 border border-blue-100">
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
{faNum(items.length)} قلم
|
||||||
<span>متریال</span>
|
</span>
|
||||||
<span>برند</span>
|
</div>
|
||||||
<span>مشخصه</span>
|
<p className="text-[11.5px] sm:text-xs text-gray-500 leading-relaxed">
|
||||||
<span>تعداد</span>
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||||
<span>واحد</span>
|
</p>
|
||||||
<span>قیمت واحد</span>
|
|
||||||
<span>قیمت کل</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-field="line_items" className="flex flex-col gap-2">
|
{/* لیست کارتهای اقلام */}
|
||||||
{items.map((it) => (
|
<div data-field="line_items" className="flex flex-col gap-3">
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
{items.map((it, idx) => (
|
||||||
|
<div
|
||||||
|
key={it.tempId}
|
||||||
|
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs space-y-3 transition-all"
|
||||||
|
>
|
||||||
|
{/* هدر قلم با شماره و نام و دکمه حذف */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 pb-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||||
|
{faNum(idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-gray-900 truncate">
|
||||||
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => remove(it.tempId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">حذف</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ورودیهای متریال و برند */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">متریال</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.cat}
|
value={it.cat}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
items={allMaterialNames}
|
items={allMaterialNames}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان متریال جدید"
|
addSuffix="به عنوان متریال جدید"
|
||||||
placeholder="متریال"
|
placeholder="نام متریال"
|
||||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||||
onBlur={() => rememberField("i-cat", it)}
|
onBlur={() => rememberField("i-cat", it)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.brand}
|
value={it.brand}
|
||||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||||
@@ -216,9 +250,16 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||||
onBlur={() => rememberField("i-brand", it)}
|
onBlur={() => rememberField("i-brand", it)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ورودیهای مشخصه و واحد */}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={it.specs}
|
values={it.specs}
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
@@ -226,24 +267,17 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
placeholder="مشخصه"
|
placeholder="مشخصه"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
</div>
|
||||||
value={it.qty}
|
|
||||||
onChange={(qty) => {
|
<div className="flex flex-col gap-1">
|
||||||
patch(it.tempId, {
|
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
||||||
qty,
|
|
||||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="تعداد"
|
|
||||||
showWords
|
|
||||||
/>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.unit}
|
value={it.unit}
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان واحد جدید"
|
addSuffix="به عنوان واحد جدید"
|
||||||
placeholder="واحد"
|
placeholder="واحد سنجش"
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
onAdd={(v) => {
|
onAdd={(v) => {
|
||||||
const name = v.trim();
|
const name = v.trim();
|
||||||
@@ -257,9 +291,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}}
|
}}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* جعبه مالی و مقادیر (تعداد، قیمت واحد، قیمت کل) */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.qty}
|
||||||
|
onChange={(qty) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
qty,
|
||||||
|
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="تعداد"
|
||||||
|
showWords
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.price}
|
value={it.price}
|
||||||
onChange={(price) => {
|
onChange={(price) => {
|
||||||
@@ -268,10 +324,14 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
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 * 100) / 100 : it.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت واحد"
|
placeholder="واحد"
|
||||||
showWords
|
showWords
|
||||||
maxDecimals={2}
|
maxDecimals={2}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.total}
|
value={it.total}
|
||||||
onChange={(total) => {
|
onChange={(total) => {
|
||||||
@@ -281,32 +341,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
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) * 100) / 100 : it.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت کل"
|
placeholder="جمع"
|
||||||
showWords
|
showWords
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => remove(it.tempId)}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
{/* دکمه افزودن قلم جدید */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-gray-300 bg-gray-50/50 py-2.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50 active:scale-[0.99] transition-all"
|
||||||
onClick={add}
|
onClick={add}
|
||||||
>
|
>
|
||||||
<span className="text-base font-bold leading-none">+</span>
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن قلم جدید</span>
|
<span>افزودن قلم جدید</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,40 +9,58 @@ export interface StepperProps {
|
|||||||
onGoto: (step: number) => void;
|
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) {
|
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||||
{STEPS.map((label, i) => {
|
{STEPS.map(({ id: step, label }) => {
|
||||||
const step = i + 1;
|
|
||||||
const isCurrent = step === current;
|
const isCurrent = step === current;
|
||||||
const isComplete = completedSteps.has(step);
|
const isComplete = completedSteps.has(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={step}
|
key={step}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||||
|
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-[#2563eb] font-bold text-white"
|
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||||
: isComplete
|
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||||
? "bg-green-bg font-medium text-green"
|
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||||
: "hover:bg-slate-bg",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onGoto(step)}
|
onClick={() => onGoto(step)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
isCurrent
|
||||||
|
? "bg-white/20 text-white"
|
||||||
|
: isComplete
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||||
|
: "bg-muted/15 text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||||
|
!isCurrent && "hidden md:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -127,11 +127,9 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (_rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها ----------
|
||||||
const projectNames = useMemo(() => {
|
const projectNames = useMemo(() => {
|
||||||
const names = new Set<string>(projects.map((p) => p.name));
|
const names = new Set<string>(projects.map((p) => p.name));
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
@@ -159,7 +157,7 @@ export function WizardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- خلاصهی مرحله ۵ ----------
|
// ---------- خلاصه مرحله ۵ ----------
|
||||||
const review = useMemo(() => {
|
const review = useMemo(() => {
|
||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
@@ -202,23 +200,14 @@ export function WizardPage() {
|
|||||||
|
|
||||||
const combined = `${field} ${msg}`.toLowerCase();
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
// ۱. تأمینکننده
|
|
||||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
|
||||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
return { step: 2, field: field || "line_items" };
|
return { step: 2, field: field || "line_items" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
|
||||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
return { step: 3, field: field || "termsheets" };
|
return { step: 3, field: field || "termsheets" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۴. پروژه
|
|
||||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
|
||||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
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 (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
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;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
|
||||||
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
|
||||||
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
|
||||||
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
|
||||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
const matched = matchMaterial(
|
const matched = matchMaterial(
|
||||||
@@ -426,7 +411,7 @@ export function WizardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-10">
|
||||||
<Header
|
<Header
|
||||||
title="افزودن پیشنهاد جدید"
|
title="افزودن پیشنهاد جدید"
|
||||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
@@ -434,10 +419,12 @@ export function WizardPage() {
|
|||||||
backLabel="بازگشت به دفتر"
|
backLabel="بازگشت به دفتر"
|
||||||
/>
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
|
{/* نوار مراحل استپر */}
|
||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
|
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -461,12 +448,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -477,7 +464,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -491,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
@@ -506,15 +493,14 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
{/* پاورقی کنترل مراحل */}
|
||||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
|
||||||
<div>
|
<div>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToStep(currentStep - 1)}
|
onClick={() => goToStep(currentStep - 1)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<span>قبلی</span>
|
<span>قبلی</span>
|
||||||
@@ -522,21 +508,19 @@ export function WizardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
|
||||||
>
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>بعدی</span>
|
<span>بعدی</span>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -547,7 +531,7 @@ export function WizardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>ذخیره پیشنهاد</span>
|
<span>ذخیره پیشنهاد</span>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -556,21 +540,21 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
{/* دیالوگ فیلدهای خالی */}
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-[13.5px] leading-7">
|
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||||
{emptyFields.map((f) => (
|
{emptyFields.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.step}
|
key={f.step}
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
goToStep(f.step);
|
goToStep(f.step);
|
||||||
|
|||||||
Reference in New Issue
Block a user