refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar

Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev
docker-compose.yml change (b11cb36) and backups churn excluded.
This commit is contained in:
MMD
2026-09-13 18:07:27 +03:30
parent a542e4177e
commit 2573f1e583
8 changed files with 549 additions and 383 deletions
+29 -36
View File
@@ -41,9 +41,7 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name;
}
// نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
// می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند.
// نام پایه‌ی متریال از نامِ ذخیره‌شده
function materialBaseName(name: string, brand: string, specs: string[]): string {
let base = (name || "").trim();
if (!base) return "";
@@ -60,9 +58,7 @@ function materialBaseName(name: string, brand: string, specs: string[]): string
return base || (name || "").trim();
}
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند)
// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها)
// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7)
// مقادیر مجزای category/brand/spec داخل تاپیک
export function materialOptions(
materials: Material[],
topic: string,
@@ -75,7 +71,6 @@ export function materialOptions(
const cat = item.cat.trim();
const brand = item.brand.trim();
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
const out = new Set<string>();
allItems.forEach((it) => {
@@ -94,7 +89,6 @@ export function materialOptions(
}
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
if (field === "brand") {
// همه‌ی برندها از همه‌ی materials + برندهای ثبت‌شده در بخش برندها (بک‌اند)
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));
@@ -110,21 +104,18 @@ export function materialOptions(
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials();
// واحدها از بک‌اند خوانده می‌شوند + واحدهای واردشده در سطرهای قبلی
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
const all = new Set<string>();
items.forEach((it) => { if (it.unit) all.add(it.unit); });
return [...all];
});
// واحدهای موجود (دیتابیس) + واحدهای تایپ‌شده در سطرهای دیگر
const allUnits = useMemo(() => {
const out = new Set(unitOptions);
items.forEach((it) => { if (it.unit) out.add(it.unit); });
return [...out];
}, [items, unitOptions]);
// نام‌های پایه‌ی یکتای همه‌ی متریال‌های کاتالوگ سراسری (فقط خود متریال،
// بدون برند/مشخصه — آن‌ها ستون‌های خودشان را دارند)
const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
@@ -134,7 +125,6 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
// برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها
const [brandOptions, setBrandOptions] = useState<string[]>([]);
useEffect(() => {
@@ -146,11 +136,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
return [...merged];
}))
.catch(() => { /* بک‌اند در دسترس نیست */ });
.catch(() => {});
void api
.brandsList()
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
.catch(() => { /* بک‌اند در دسترس نیست */ });
.catch(() => {});
return () => { alive = false; };
}, []);
@@ -163,14 +153,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
};
const add = () => {
// سطر جدید — مقدارها خالی؛ پیشنهادها از همه‌ی سطرهای قبلی می‌آیند
onItemsChange([
...items,
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
]);
};
// یادآوری یک فیلد تایپ‌شده برای پیشنهادهای آینده (داخل تاپیک)
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return;
let rec: MaterialRec;
@@ -198,10 +186,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
return (
<div>
<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-accent text-[12px] font-bold text-white">۲</span>
<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>
اقلام سفارش
</h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
<span>متریال</span>
@@ -215,12 +203,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<div className="flex flex-col gap-2">
{items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row">
<div key={it.tempId} className="item-row wizard-item-row items-center">
<Combobox
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
// پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
@@ -263,30 +249,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<Combobox
value={it.unit}
onChange={(x) => 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) => {
// انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
// نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (onInputChange بالا)
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(() => {
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
// خطا دهد، هنگام ذخیره‌ی پیشنهاد دوباره resolve می‌شود — خطای بلوک‌کننده نیست
});
.catch(() => {});
}}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
@@ -315,16 +293,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت کل"
showWords
/>
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
×
{/* دکمه حذف مدرن با آیکون سطل زباله */}
<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>
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
+ افزودن قلم
{/* دکمه افزودن قلم با استایل برجسته و خط‌چین */}
<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"
onClick={add}
>
<span className="text-base font-bold leading-none">+</span>
<span>افزودن قلم جدید</span>
</button>
</div>
);
}
}
+35 -12
View File
@@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react";
import { faNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { FileUpload } from "../../components/FileUpload";
export interface Step5Props {
quoteDate: string | null;
@@ -52,7 +51,7 @@ export function Step5Review({
return (
<div>
<h3 className="mb-3 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۵</span>
<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>
تاریخ، یادداشت و بازبینی نهایی
</h3>
@@ -78,19 +77,43 @@ export function Step5Review({
/>
</div>
<div className="field col-span-2">
<label>پیش‌فاکتور (اختیاری)</label>
<label className="mb-1.5 block text-xs font-medium text-gray-700">پیش‌فاکتور (اختیاری)</label>
{proforma ? (
<div className="inv-row">
<span className="inv-name">
<Paperclip className="me-1 inline h-3.5 w-3.5" />
{proforma.name}
</span>
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}>
×
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-xs">
<div className="flex items-center gap-2 truncate text-gray-700">
<Paperclip className="h-4 w-4 text-blue-600 shrink-0" />
<span className="font-medium truncate">{proforma.name}</span>
<span className="text-[11px] text-gray-400 shrink-0">
({Math.round(proforma.size / 1024)} کیلوبایت)
</span>
</div>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="حذف فایل"
onClick={() => onProforma(null)}
>
<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>
) : (
<FileUpload onUpload={onProforma} label="انتخاب فایل پیش‌فاکتور" />
/* دکمه آپلود مینیمال با حاشیه خط‌چین ملایم */
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-gray-300 bg-gray-50/50 px-4 py-2 text-xs font-medium text-gray-600 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all">
<Paperclip className="h-4 w-4 text-gray-400" />
<span>انتخاب یا پیوست فایل پیش‌فاکتور</span>
<input
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg,.xlsx,.xls,.docx"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onProforma(f);
}}
/>
</label>
)}
</div>
</div>
@@ -114,4 +137,4 @@ export function Step5Review({
</div>
</div>
);
}
}
+168 -156
View File
@@ -73,18 +73,15 @@ export function WizardPage() {
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
// تاریخ پیشنهاد: همین الان (ساعت و دقیقه‌ی فعلی) — نه نیمه‌شب
return new Date().toISOString();
});
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [proforma, setProforma] = useState<File | null>(null);
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ----------
// وقتی suppliers هنوز لود نشده، دوباره اجرا می‌شود
useEffect(() => {
const t = params.get("topic");
if (!t) return;
@@ -102,23 +99,17 @@ export function WizardPage() {
}, [params, suppliers, topics]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
// مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند
const completedSteps = useMemo(() => {
const s = new Set<number>();
// مرحله ۱: تاپیک انتخاب شده + نام تأمین‌کننده پر شده
if (selectedTopic && supplierName.trim()) s.add(1);
// مرحله ۲: حداقل یک قلم با تعداد و قیمت
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
// مرحله ۳: حداقل یک termsheet با مایل‌ستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیش‌فرض
if (
cards.length > 0 &&
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
) {
s.add(3);
}
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
// مرحله ۵: همیشه بازدیدشده محسوب می‌شود
if (highestVisited >= 5) s.add(5);
return s;
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
@@ -200,144 +191,133 @@ export function WizardPage() {
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
// ---------- ذخیره ----------
// تابع ذخیره‌ی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
const doSave = async () => {
// خطای بخش — با دکمه‌ی «رفع خطا» که به همان بخش می‌برد
const sectionError = (step: number, title: string, err: unknown) => {
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
toast.error(`${title}: ${msg}`, {
action: { label: "رفع خطا", onClick: () => goToStep(step) },
});
};
// مشخص کردن بخش خطا از روی متن پیام
const guessStep = (msg: string): number => {
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
if (/پروژه|project/i.test(msg)) return 4;
return 5;
};
const sectionError = (step: number, title: string, err: unknown) => {
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
toast.error(`${title}: ${msg}`, {
action: { label: "رفع خطا", onClick: () => goToStep(step) },
});
};
const guessStep = (msg: string): number => {
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
if (/پروژه|project/i.test(msg)) return 4;
return 5;
};
// تأمین‌کننده
const name = supplierName.trim();
let supplierId = "";
if (name) {
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
if (exist) {
supplierId = exist.id;
} else {
try {
const created = await addSupplier.mutateAsync({
id: nextId("s", suppliers),
topic: selectedTopic,
categories: [],
name,
location: newSup.location.trim(),
phone: newSup.phone.trim(),
contact: newSup.contact.trim(),
});
supplierId = created.id;
} catch (err) {
sectionError(1, "خطا در بخش تأمین‌کننده", err);
return;
}
const name = supplierName.trim();
let supplierId = "";
if (name) {
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
if (exist) {
supplierId = exist.id;
} else {
try {
const created = await addSupplier.mutateAsync({
id: nextId("s", suppliers),
topic: selectedTopic,
categories: [],
name,
location: newSup.location.trim(),
phone: newSup.phone.trim(),
contact: newSup.contact.trim(),
});
supplierId = created.id;
} catch (err) {
sectionError(1, "خطا در بخش تأمین‌کننده", err);
return;
}
}
}
const collected: { li: LineItem; tempId: string }[] = [];
for (const it of items) {
const cat = it.cat.trim();
const brand = it.brand.trim();
const specs = it.specs;
const unit = it.unit.trim();
const qty = it.qty;
const price = it.price;
const total = it.total;
if (!cat && !brand && qty == null && price == null) continue;
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
if (!material) {
material = materials.find(
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
}
let materialId: string;
if (material) {
materialId = material.id;
} else {
try {
const created = await addMaterial.mutateAsync({
id: nextId("m", materials),
name: wantName,
topic: selectedTopic,
category: cat,
brand,
specs,
base_unit: unit,
});
materialId = created.id;
} catch (err) {
sectionError(2, "خطا در بخش اقلام (متریال)", err);
return;
}
}
// اقلام
const collected: { li: LineItem; tempId: string }[] = [];
for (const it of items) {
const cat = it.cat.trim();
const brand = it.brand.trim();
const specs = it.specs;
const unit = it.unit.trim();
const qty = it.qty;
const price = it.price;
const total = it.total;
if (!cat && !brand && qty == null && price == null) continue;
// جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک،
// چون دسته‌ی ذخیره‌شده‌ی متریال‌ها نام تاپیک است نه نام تایپ‌شده‌ی کاربر
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
if (!material) {
// جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست)
material = materials.find(
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
}
let materialId: string;
if (material) {
materialId = material.id;
} else {
try {
const created = await addMaterial.mutateAsync({
id: nextId("m", materials),
name: wantName,
topic: selectedTopic,
category: cat,
brand,
specs,
base_unit: unit,
});
materialId = created.id; // UUID واقعی از بک‌اند
} catch (err) {
sectionError(2, "خطا در بخش اقلام (متریال)", err);
return;
}
}
collected.push({
li: {
id: "li-" + (collected.length + 1),
material_id: materialId,
quantity: qty,
unit,
unit_price: price,
total_price: total,
termsheet_ids: [],
accepted_ts_id: null,
rejected: false,
arrived_date: null,
},
tempId: it.tempId,
});
}
const lineItems = collected.map((c) => c.li);
// termsheetها
const termsheets = cards
.map((card, i) => ({
id: "new-ts-" + (i + 1),
label: "گزینه " + faNum(i + 1),
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
status: "offered" as const,
}))
.filter((t) => t.milestones.length);
// اتصال اقلام به termsheetها
collected.forEach(({ li, tempId }) => {
const ids = cards
.map((card, i) =>
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
)
.filter(Boolean);
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
collected.push({
li: {
id: "li-" + (collected.length + 1),
material_id: materialId,
quantity: qty,
unit,
unit_price: price,
total_price: total,
termsheet_ids: [],
accepted_ts_id: null,
rejected: false,
arrived_date: null,
},
tempId: it.tempId,
});
}
// پروژه‌ها
const projSet = new Set<string>();
collected.forEach(({ li, tempId }) => {
const list = assignment[tempId] || [];
li.projects = list.map((p) => ({ id: "", name: p }));
list.forEach((p) => projSet.add(p));
});
const projectsList = [...projSet];
const lineItems = collected.map((c) => c.li);
const termsheets = cards
.map((card, i) => ({
id: "new-ts-" + (i + 1),
label: "گزینه " + faNum(i + 1),
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
status: "offered" as const,
}))
.filter((t) => t.milestones.length);
collected.forEach(({ li, tempId }) => {
const ids = cards
.map((card, i) =>
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
)
.filter(Boolean);
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
});
const projSet = new Set<string>();
collected.forEach(({ li, tempId }) => {
const list = assignment[tempId] || [];
li.projects = list.map((p) => ({ id: "", name: p }));
list.forEach((p) => projSet.add(p));
});
const projectsList = [...projSet];
const quoteIso = quoteDate || new Date().toISOString();
const quote: Quote = {
@@ -361,7 +341,6 @@ export function WizardPage() {
try {
const created = await createQuote.mutateAsync(quote);
// آپلود پیش‌فاکتور به MinIO بعد از ساخت پیشنهاد
if (proforma) {
try {
await api.files.upload(created.id, "proforma", proforma);
@@ -379,7 +358,6 @@ export function WizardPage() {
}
};
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز می‌شود
const save = () => {
const incomplete: { label: string; step: number }[] = [];
if (!completedSteps.has(1)) incomplete.push({ label: "تأمین‌کننده", step: 1 });
@@ -475,19 +453,53 @@ export function WizardPage() {
</div>
)}
{/* پاورقی ویزارد */}
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm">
{currentStep > 1 && (
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}>
‹ قبلی
</Button>
)}
<div className="flex-1" />
<Link to="/" className="btn-line-file no-underline">
انصراف
</Link>
{currentStep < TOTAL_STEPS && <Button onClick={next}>بعدی ›</Button>}
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>}
{/* پاورقی ویزارد — طراحی بازسازی‌شده با ناوبری استاندارد */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
{/* دکمه مرحله قبلی */}
<div>
{currentStep > 1 && (
<button
type="button"
onClick={() => goToStep(currentStep - 1)}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs"
>
<span>›</span>
<span>قبلی</span>
</button>
)}
</div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
<div className="flex items-center gap-3">
<Link
to="/"
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
>
انصراف
</Link>
{currentStep < TOTAL_STEPS && (
<button
type="button"
onClick={next}
className="inline-flex items-center gap-2 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"
>
<span>بعدی</span>
<span className="text-xs">‹</span>
</button>
)}
{currentStep === TOTAL_STEPS && (
<button
type="button"
onClick={() => void save()}
className="inline-flex items-center gap-2 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"
>
<span>ذخیره پیشنهاد</span>
<span className="text-xs">✓</span>
</button>
)}
</div>
</div>
{/* دیالوگ ذخیره با فیلدهای خالی */}
@@ -539,4 +551,4 @@ export function WizardPage() {
</PageLayout>
</div>
);
}
}