feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
@@ -0,0 +1,193 @@
import { useMemo, useState } from "react";
import type { Material, Supplier } from "../../lib/types";
import { TOPICS } from "../../lib/constants";
import { normalize } from "../../lib/normalize";
import { useSuppliers } from "../../hooks";
import { TopicGrid } from "../../components/TopicGrid";
import { Combobox } from "../../components/Combobox";
import { cn } from "../../lib/utils";
export interface Step1Props {
selectedTopic: string;
onTopicSelect: (topic: string) => void;
supplierName: string;
onSupplierName: (name: string) => void;
newSup: { location: string; phone: string; contact: string };
onNewSup: (patch: Partial<{ location: string; phone: string; contact: string }>) => void;
isOfficial: boolean;
onOfficial: (v: boolean) => void;
invalid: boolean;
}
// مرحله ۱ — تاپیک + تأمین‌کننده (چیپ‌های سریع + فرم تأمین‌کننده‌ی جدید)
export function Step1TopicSupplier({
selectedTopic,
onTopicSelect,
supplierName,
onSupplierName,
newSup,
onNewSup,
isOfficial,
onOfficial,
invalid,
}: Step1Props) {
const suppliers = useSuppliers();
const [selectedSupId, setSelectedSupId] = useState<string | null>(null);
const topicSups = useMemo(
() => (selectedTopic ? suppliers.filter((s) => normalize(s.topic) === normalize(selectedTopic)) : []),
[suppliers, selectedTopic],
);
const counts = useMemo(() => {
const out: Record<string, number> = {};
TOPICS.forEach((t) => {
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
});
return out;
}, [suppliers]);
// آیا تأمین‌کننده‌ی فعلی در لیست هست؟
const matched = useMemo(() => {
const n = normalize(supplierName);
if (!n) return null;
return topicSups.find((s) => normalize(s.name) === n) || null;
}, [topicSups, supplierName]);
const showNewSupForm = supplierName.trim().length > 0 && !matched;
const pickSupplier = (s: Supplier) => {
onSupplierName(s.name);
setSelectedSupId(s.id);
};
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>
موضوع (دسته‌بندی کالا)
</h3>
<div className="mb-3 text-[12px] text-muted">
اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمین‌کننده‌ها بر اساس موضوع فیلتر می‌شوند.
</div>
<TopicGrid
selectedTopic={selectedTopic}
onSelect={onTopicSelect}
showCounts
counts={counts}
/>
{selectedTopic && (
<div className="mt-5">
<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>
تأمین‌کننده
</h3>
<div className="mb-2 flex flex-wrap gap-1.5">
{!topicSups.length ? (
<div className="chip-divider">
هنوز تأمین‌کننده‌ای در این موضوع ثبت نشده — پایین را تایپ کنید تا به‌عنوان تأمین‌کننده‌ی جدید ذخیره شود.
</div>
) : (
topicSups.map((s) => (
<button
key={s.id}
type="button"
className={cn("quick-chip", selectedSupId === s.id && "selected")}
onClick={() => pickSupplier(s)}
>
{s.name} <span className="qc-sub">{s.location || ""}</span>
</button>
))
)}
</div>
<div className={cn("field max-w-md", invalid && "invalid")}>
<label>یا نام تأمین‌کننده را تایپ و انتخاب کنید</label>
<Combobox
value={supplierName}
onChange={(it: { id: string; label: string }) => {
onSupplierName(it.label);
setSelectedSupId(it.id === it.label ? null : it.id);
}}
items={topicSups.map((s) => ({ id: s.id, name: s.name }))}
allowAdd
addSuffix="به عنوان تأمین‌کنندهٔ جدید"
placeholder="تایپ کنید... مثلاً زاگرس"
onInputChange={(v) => {
onSupplierName(v);
setSelectedSupId(null);
}}
/>
</div>
{showNewSupForm && (
<div className="mt-3 rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-3">
<div className="mb-2 text-[12px] text-muted">
این تأمین‌کننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود:
</div>
<div className="grid max-w-2xl grid-cols-2 gap-3">
<div className="field">
<label>موقعیت (شهر - منطقه)</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="مثلاً اصفهان - ذوب آهن"
value={newSup.location}
onChange={(e) => onNewSup({ location: e.target.value })}
/>
</div>
<div className="field">
<label>تلفن</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="۰۲۱-..."
value={newSup.phone}
onChange={(e) => onNewSup({ phone: e.target.value })}
/>
</div>
<div className="field">
<label>شخص رابط</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="نام فرد"
value={newSup.contact}
onChange={(e) => onNewSup({ contact: e.target.value })}
/>
</div>
</div>
</div>
)}
{supplierName.trim() && (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-[13px]">
<input
type="checkbox"
className="accent-accent"
checked={isOfficial}
onChange={(e) => onOfficial(e.target.checked)}
/>
فاکتور رسمی
<span className="text-[11px] text-muted">
(تأمین‌کننده برای این معامله فاکتور رسمی صادر می‌کند — نه فقط توافق تلفنی)
</span>
</label>
)}
</div>
)}
</div>
);
}
// دسته‌های موجود داخل یک تاپیک (از materials + تأمین‌کننده‌ها)
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
const out = new Set<string>();
materials
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
.forEach((m) => out.add(m.category));
suppliers
.filter((s) => topic && normalize(s.topic) === normalize(topic))
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from "react";
import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks";
import { specsKey } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api } from "../../lib/api";
export interface ItemDraft {
tempId: string;
cat: string;
brand: string;
specs: string[];
qty: number | null;
unit: string;
price: number | null;
total: number | null;
}
export interface MaterialRec {
topic: string;
category: string;
brand: string;
specs: string[];
}
export interface Step2Props {
items: ItemDraft[];
onItemsChange: (items: ItemDraft[]) => void;
selectedTopic: string;
onRemember: (rec: MaterialRec) => void;
}
// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی)
export function itemRowLabel(it: ItemDraft): string {
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)";
const qtyPart = it.qty != null && it.unit ? `${it.qty} ${it.unit}` : it.qty != null ? String(it.qty) : "";
return qtyPart ? `${name} -- ${qtyPart}` : name;
}
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند)
// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها)
// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7)
export function materialOptions(
materials: Material[],
topic: string,
item: ItemDraft,
field: "cat" | "brand" | "spec",
allItems: ItemDraft[] = [],
apiBrands: string[] = [],
): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
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) => {
if (it.tempId === item.tempId) return;
const v = sel(it);
if (Array.isArray(v)) v.forEach((x) => x && out.add(x));
else if (v) out.add(v);
});
return [...out];
};
if (field === "cat") {
const base = new Set(list.map((m) => m.category).filter(Boolean));
fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
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));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
}
if (brand) list = list.filter((m) => normalize(m.brand) === normalize(brand));
const base = new Set(list.flatMap((m) => (m.specs || []).filter(Boolean)));
fromRows((it) => it.specs).forEach((s) => base.add(s));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
// مرحله ۲ — جدول پویای اقلام سفارش
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 [brandOptions, setBrandOptions] = useState<string[]>([]);
useEffect(() => {
let alive = true;
void api
.unitsList()
.then((list) => alive && setUnitOptions((prev) => {
const merged = new Set(prev);
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
return [...merged];
}))
.catch(() => { /* بک‌اند در دسترس نیست */ });
void api
.brandsList()
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
.catch(() => { /* بک‌اند در دسترس نیست */ });
return () => { alive = false; };
}, []);
const patch = (tempId: string, p: Partial<ItemDraft>) => {
onItemsChange(items.map((it) => (it.tempId === tempId ? { ...it, ...p } : it)));
};
const remove = (tempId: string) => {
onItemsChange(items.filter((it) => it.tempId !== tempId));
};
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;
if (field === "i-cat") {
if (!it.cat.trim()) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: "", specs: [] };
} else if (field === "i-brand") {
if (!it.brand.trim()) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: [] };
} else {
if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
}
const dup = materials.some(
(m) =>
normalize(m.topic) === normalize(rec.topic) &&
normalize(m.category) === normalize(rec.category) &&
normalize(m.brand) === normalize(rec.brand) &&
specsKey(m.specs) === specsKey(rec.specs),
);
if (dup) return;
onRemember(rec);
};
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>
اقلام سفارش
</h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
<span>متریال</span>
<span>برند</span>
<span>مشخصه</span>
<span>تعداد</span>
<span>واحد</span>
<span>قیمت واحد</span>
<span>قیمت کل</span>
</div>
<div className="flex flex-col gap-2">
{items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row">
<Combobox
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
items={materialOptions(materials, selectedTopic, it, "cat", items)}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<Combobox
value={it.brand}
onChange={(x) => 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"
/>
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items)}
placeholder="مشخصه"
className="min-w-0"
/>
<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
/>
<Combobox
value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits}
allowAdd
addSuffix="اضافه کردن واحد جدید"
placeholder="واحد"
onInputChange={(v) => patch(it.tempId, { unit: v })}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
});
}}
placeholder="قیمت واحد"
showWords
maxDecimals={2}
/>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
});
}}
placeholder="قیمت کل"
showWords
/>
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
×
</button>
</div>
))}
</div>
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
+ افزودن قلم
</button>
</div>
);
}
@@ -0,0 +1,70 @@
import type { Milestone } from "../../lib/types";
import { TermsheetCard } from "../../components/TermsheetCard";
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
export interface TsCardDraft {
key: string;
milestones: Milestone[];
}
export interface Step3Props {
cards: TsCardDraft[];
onCardsChange: (cards: TsCardDraft[]) => void;
items: ItemDraft[];
coverage: Record<string, string[]>; // cardKey → tempIdهای تیک‌خورده
onCoverageChange: (cardKey: string, ids: string[]) => void;
}
// پوشش مؤثر هر کارت: تیک‌های صریح، یا (کارت اول بدون تیک) همه‌ی اقلام
export function effectiveCovered(cards: TsCardDraft[], coverage: Record<string, string[]>, items: ItemDraft[], cardKey: string): string[] {
const explicit = coverage[cardKey];
if (explicit && explicit.length) return explicit;
if (cards[0]?.key === cardKey) return items.map((i) => i.tempId);
return [];
}
// مرحله ۳ — شرایط پرداخت (termsheetها) با چک‌لیست اقلام
export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCoverageChange }: Step3Props) {
const itemOptions = items.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }));
const addCard = () => {
onCardsChange([
...cards,
{ key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6), milestones: [{ when_iso: null, amount: null }] },
]);
};
const removeCard = (key: string) => {
onCardsChange(cards.filter((c) => c.key !== key));
onCoverageChange(key, []);
};
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>
شرایط پرداخت — termsheet ها
</h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلم‌ها را پوشش می‌دهد — یک termsheet می‌تواند چند قلم را بگیرد، و یک قلم هم می‌تواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
<div className="flex flex-col gap-3">
{cards.map((card, idx) => (
<TermsheetCard
key={card.key}
index={idx + 1}
milestones={card.milestones}
onMilestonesChange={(ms) => onCardsChange(cards.map((c) => (c.key === card.key ? { ...c, milestones: ms } : c)))}
items={itemOptions}
coveredIds={effectiveCovered(cards, coverage, items, card.key)}
onCoverageChange={(ids) => onCoverageChange(card.key, ids)}
onRemove={() => removeCard(card.key)}
/>
))}
</div>
<button type="button" className="btn-line-file mt-3" onClick={addCard}>
+ افزودن termsheet
</button>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useState } from "react";
import { DndContext, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import { faNum } from "../../lib/formatters";
import { normalize } from "../../lib/normalize";
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
import { cn } from "../../lib/utils";
export interface Step4Props {
items: ItemDraft[];
projects: string[];
assignment: Record<string, string[]>; // tempId → project names
onAssign: (tempId: string, proj: string) => void;
onUnassign: (tempId: string, proj: string) => void;
onAddProject: (name: string) => void;
}
const ALL_KEY = "__all__";
function DraggableItem({
item,
label,
assignedCount,
assignedNames,
picked,
isAll,
onPick,
}: {
item?: ItemDraft;
label: string;
assignedCount: number;
assignedNames: string[];
picked: boolean;
isAll: boolean;
onPick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: isAll ? "item-" + ALL_KEY : "item-" + item!.tempId,
data: { tempId: isAll ? ALL_KEY : item!.tempId, isAll },
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onPick}
className={cn(
"dd-item",
!isAll && assignedCount === 0 && "dd-unassigned",
picked && "picked",
isDragging && "dragging",
isAll && "border-accent/40 bg-accent-bg/30",
)}
style={{ transform: CSS.Translate.toString(transform) }}
>
<div className="min-w-0">
<span className="dd-label">{label}</span>
{assignedNames.length > 0 && (
<div className="mt-0.5 text-[10px] text-muted">لینک‌شده به: {assignedNames.join("، ")}</div>
)}
</div>
<span className="dd-count">{isAll ? "" : assignedCount ? faNum(assignedCount) + " پروژه" : "بدون پروژه"}</span>
</div>
);
}
function DroppableProject({
name,
tags,
onTagRemove,
onPickProject,
}: {
name: string;
tags: { tempId: string; label: string }[];
onTagRemove: (tempId: string) => void;
onPickProject: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: "proj-" + name, data: { proj: name } });
return (
<div
ref={setNodeRef}
onClick={onPickProject}
className={cn("dd-project", isOver && "drop-hover")}
>
<div className="dp-name">🏗️ {name}</div>
<div className="dp-tags">
{tags.length ? (
tags.map((t) => (
<span key={t.tempId} className="dd-tag">
{t.label}{" "}
<button
type="button"
className="dd-tag-x"
title="حذف از این پروژه"
onClick={(e) => {
e.stopPropagation();
onTagRemove(t.tempId);
}}
>
×
</button>
</span>
))
) : (
<div className="dd-proj-empty">قلمی برای این پروژه رها نشده</div>
)}
</div>
</div>
);
}
// مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه)
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState("");
// فاصله‌ی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
// نه شروع درگ (بدون این، dnd-kit رویداد click را می‌بلعد و کلیک-انتخاب کار نمی‌کند)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
// افزودن پروژه به‌صورت درجا — بدون رفتن به صفحه‌ی پروژه‌ها
const addProjectInline = () => {
const name = newProj.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
}
onAddProject(name);
setNewProj("");
toast.success("پروژه افزوده شد.");
};
const assignAll = (proj: string) => {
items.forEach((it) => onAssign(it.tempId, proj));
};
const onDragEnd = (e: DragEndEvent) => {
const tempId = (e.active.data.current as { tempId?: string; isAll?: boolean } | undefined)?.tempId;
const proj = (e.over?.data.current as { proj?: string } | undefined)?.proj;
if (!proj) return;
if (tempId === ALL_KEY) {
// همه‌ی اقلام به این پروژه
assignAll(proj);
} else if (tempId) {
onAssign(tempId, proj);
}
};
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>
پروژه‌ها
</h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'یک تماس می‌تواند درباره‌ی چند پروژه باشد. هر قلم را روی پروژه‌ی مربوطه <b>بکشید و رها کنید</b> (یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید). هر قلم می‌تواند روی چند پروژه باشد. گزینه‌ی «همه اقلام» همه‌ی اقلام را به پروژه‌ی موردنظر اختصاص می‌دهد.' }} />
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div className="dd-panels">
<div className="dd-col dd-items-col">
<h4 className="mb-1 text-[13px] font-bold">اقلام</h4>
<div className="dd-hint mb-2 text-[11px] text-muted">
قلم را بکشید و روی پروژه رها کنید — یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید
</div>
{/* همه اقلام — اختصاص همه به یک پروژه */}
<DraggableItem
label="همه اقلام"
assignedCount={0}
assignedNames={[]}
picked={picked === ALL_KEY}
isAll
onPick={() => setPicked(picked === ALL_KEY ? null : ALL_KEY)}
/>
{items.map((it) => (
<DraggableItem
key={it.tempId}
item={it}
label={itemRowLabel(it)}
assignedCount={(assignment[it.tempId] || []).length}
assignedNames={assignment[it.tempId] || []}
picked={picked === it.tempId}
isAll={false}
onPick={() => setPicked(picked === it.tempId ? null : it.tempId)}
/>
))}
</div>
<div className="dd-col dd-projects-col">
<h4 className="mb-1 text-[13px] font-bold">پروژه‌ها</h4>
{projects.map((pname) => (
<DroppableProject
key={pname}
name={pname}
tags={items
.filter((it) => (assignment[it.tempId] || []).includes(pname))
.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }))}
onTagRemove={(tempId) => onUnassign(tempId, pname)}
onPickProject={() => {
if (picked) {
if (picked === ALL_KEY) assignAll(pname);
else onAssign(picked, pname);
setPicked(null);
}
}}
/>
))}
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */}
<div className="mt-3 rounded-md border border-dashed border-line p-2">
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div>
<div className="flex items-center gap-2">
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="نام پروژه"
autoComplete="off"
value={newProj}
onChange={(e) => setNewProj(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProjectInline();
}
}}
/>
<button type="button" className="btn-line-file shrink-0 bg-accent text-white hover:bg-accent-dark" onClick={addProjectInline}>
+ افزودن
</button>
</div>
</div>
</div>
</div>
</DndContext>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
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;
onQuoteDate: (iso: string | null) => void;
expiryDate: string | null;
onExpiryDate: (iso: string | null) => void;
notes: string;
onNotes: (v: string) => void;
proforma: File | null;
onProforma: (f: File | null) => void;
// خلاصه‌ی بازبینی
summary: {
supplierName: string;
topicName: string;
projects: string[];
itemCount: number;
tsCount: number;
grandTotal: number;
isOfficial: boolean;
orphanCount: number;
};
}
// مرحله ۵ — تاریخ، یادداشت، پیش‌فاکتور و بازبینی نهایی
export function Step5Review({
quoteDate,
onQuoteDate,
expiryDate,
onExpiryDate,
notes,
onNotes,
proforma,
onProforma,
summary,
}: Step5Props) {
const rows: [string, string][] = [
["تأمین‌کننده", summary.supplierName],
["موضوع", summary.topicName],
["پروژه‌ها", summary.projects.join("، ") || "—"],
["تعداد اقلام", groupCommas(summary.itemCount, true)],
["تعداد termsheet", groupCommas(summary.tsCount, true)],
["جمع کل تقریبی", groupCommas(summary.grandTotal, true) + " تومان"],
["فاکتور رسمی", summary.isOfficial ? "بله" : "خیر"],
["پیش‌فاکتور", proforma ? proforma.name : "—"],
];
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>
تاریخ، یادداشت و بازبینی نهایی
</h3>
<div className="grid max-w-2xl grid-cols-2 gap-3">
<div className="field">
<label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
<div className="hint">پیش‌فرض امروز</div>
</div>
<div className="field">
<label>تاریخ انقضای پیشنهاد</label>
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
</div>
<div className="field col-span-2">
<label>یادداشت (اختیاری)</label>
<textarea
rows={2}
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="توضیحات اضافی درباره تماس یا توافق..."
value={notes}
onChange={(e) => onNotes(e.target.value)}
/>
</div>
<div className="field col-span-2">
<label>پیش‌فاکتور (اختیاری)</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)}>
×
</button>
</div>
) : (
<FileUpload onUpload={onProforma} label="انتخاب فایل پیش‌فاکتور" />
)}
</div>
</div>
<div className="mt-4 rounded-xl border border-line bg-panel p-4 shadow-sm">
<h4 className="mb-2 text-[13.5px] font-bold">خلاصه‌ی پیشنهاد پیش از ذخیره</h4>
{rows.map(([label, val]) => (
<div key={label} className="review-row">
<span className="r-label">{label}</span>
<span className="r-val">{val}</span>
</div>
))}
{summary.orphanCount > 0 && (
<div className="review-row">
<span className="r-label">هشدار</span>
<span className="r-val review-warn">
{faNum(summary.orphanCount)} قلم به هیچ termsheet وصل نیست -- در ذخیره‌سازی به‌طور خودکار به اولین termsheet وصل می‌شود
</span>
</div>
)}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Check } from "lucide-react";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
export interface StepperProps {
current: number;
highestVisited: number;
completedSteps: Set<number>;
onGoto: (step: number) => void;
}
const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
return (
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
{STEPS.map((label, i) => {
const step = i + 1;
const isCurrent = step === current;
const isComplete = completedSteps.has(step);
return (
<button
key={step}
type="button"
className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
isCurrent
? "bg-[#2563eb] font-bold text-white"
: isComplete
? "bg-green-bg font-medium text-green"
: "hover:bg-slate-bg",
)}
onClick={() => onGoto(step)}
>
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
)}
>
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
</span>
{label}
</button>
);
})}
</div>
);
}
+542
View File
@@ -0,0 +1,542 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types";
import { api, nextId } from "../../lib/api";
import {
useAddMaterial,
useAddProject,
useAddSupplier,
useCreateQuote,
useMaterials,
useProjects,
useQuotes,
useStoreData,
useSuppliers,
} from "../../hooks";
import { TOPICS } from "../../lib/constants";
import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { specsKey } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header";
import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier";
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
import { Step3Termsheets, effectiveCovered, type TsCardDraft } from "./Step3Termsheets";
import { Step4Projects } from "./Step4Projects";
import { Step5Review } from "./Step5Review";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
const TOTAL_STEPS = 5;
// ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید
export function WizardPage() {
useStoreData();
const navigate = useNavigate();
const [params] = useSearchParams();
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const createQuote = useCreateQuote();
const addSupplier = useAddSupplier();
const addMaterial = useAddMaterial();
const addProject = useAddProject();
// ---------- state ----------
const [currentStep, setCurrentStep] = useState(1);
const [highestVisited, setHighestVisited] = useState(1);
const [selectedTopic, setSelectedTopic] = useState("");
const [supplierName, setSupplierName] = useState("");
const [newSup, setNewSup] = useState({ location: "", phone: "", contact: "" });
const [isOfficial, setIsOfficial] = useState(false);
const [supplierInvalid, setSupplierInvalid] = useState(false);
const [items, setItems] = useState<ItemDraft[]>(() => [
{ tempId: "it0", cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
]);
const [cards, setCards] = useState<TsCardDraft[]>(() => [
{ key: "ts0", milestones: [{ when_iso: null, amount: null }] },
]);
const [coverage, setCoverage] = useState<Record<string, string[]>>({});
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).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;
const topic = TOPICS.find((x) => normalize(x.name) === normalize(t));
if (!topic) return;
setSelectedTopic(topic.name);
const sid = params.get("supplier");
if (sid) {
const sup = suppliers.find((s) => s.id === sid);
if (sup && normalize(sup.topic) === normalize(topic.name)) {
setSupplierName(sup.name);
setSupplierInvalid(false);
}
}
}, [params, suppliers]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
// مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند
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]);
const goToStep = (n: number) => {
setCurrentStep(n);
setHighestVisited((h) => Math.max(h, n));
window.scrollTo({ top: 0, behavior: "smooth" });
};
const next = () => {
goToStep(currentStep + 1);
};
const rememberMaterial = (rec: MaterialRec) => {
void addMaterial.mutateAsync({
id: nextId("m", materials),
name: [rec.brand, rec.category, ...rec.specs].filter(Boolean).join(" ") || "کالای جدید",
topic: rec.topic,
category: rec.category,
brand: rec.brand,
specs: rec.specs,
base_unit: "",
});
};
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => {
if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n));
});
return [...names];
}, [projects, quotes]);
const assign = (tempId: string, proj: string) => {
setAssignment((a) => {
const cur = a[tempId] || [];
if (cur.includes(proj)) return a;
return { ...a, [tempId]: [...cur, proj] };
});
};
const unassign = (tempId: string, proj: string) => {
setAssignment((a) => {
const cur = (a[tempId] || []).filter((p) => p !== proj);
const next = { ...a };
if (cur.length) next[tempId] = cur;
else delete next[tempId];
return next;
});
};
// ---------- خلاصه‌ی مرحله ۵ ----------
const review = useMemo(() => {
let grandTotal = 0;
items.forEach((it) => {
const t = it.total;
if (t) grandTotal += t;
});
const projSet = new Set<string>();
items.forEach((it) => (assignment[it.tempId] || []).forEach((p) => projSet.add(p)));
const coveredTempIds = new Set<string>();
cards.forEach((card) =>
effectiveCovered(cards, coverage, items, card.key).forEach((tid) => coveredTempIds.add(tid)),
);
const orphanCount = items.filter((it) => !coveredTempIds.has(it.tempId)).length;
return {
supplierName: supplierName.trim() || "—",
topicName: selectedTopic || "—",
projects: [...projSet],
itemCount: items.length,
tsCount: cards.length,
grandTotal,
isOfficial,
orphanCount,
};
}, [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 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.category) === normalize(cat) && 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] : [];
});
// پروژه‌ها
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 = {
id: nextId("q", quotes),
supplier_id: supplierId,
project: projectsList[0] || "",
projects: projectsList,
quote_date: quoteIso,
expiry_date: expiryDate,
accepted_date: null,
arrived_date: null,
status: "offered",
is_official: isOfficial,
proformas: [],
invoices: [],
events: [{ type: "offered", occurred_at: quoteIso }],
notes: notes.trim(),
line_items: lineItems,
termsheets,
};
try {
const created = await createQuote.mutateAsync(quote);
// آپلود پیش‌فاکتور به MinIO بعد از ساخت پیشنهاد
if (proforma) {
try {
await api.files.upload(created.id, "proforma", proforma);
} catch (err) {
toast.error(`خطا در آپلود پیش‌فاکتور: ${err instanceof Error ? err.message : "خطای ناشناخته"}`, {
action: { label: "رفع خطا", onClick: () => goToStep(5) },
});
}
}
toast.success("پیشنهاد ذخیره شد.");
navigate("/");
} catch (e) {
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
}
};
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز می‌شود
const save = () => {
const incomplete: { label: string; step: number }[] = [];
if (!completedSteps.has(1)) incomplete.push({ label: "تأمین‌کننده", step: 1 });
if (!completedSteps.has(2)) incomplete.push({ label: "اقلام", step: 2 });
if (!completedSteps.has(3)) incomplete.push({ label: "شرایط پرداخت", step: 3 });
if (!completedSteps.has(4)) incomplete.push({ label: "پروژه", step: 4 });
if (incomplete.length > 0) {
setEmptyFields(incomplete);
setConfirmOpen(true);
return;
}
void doSave();
};
return (
<div className="min-h-screen">
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/"
backLabel="بازگشت به دفتر"
/>
<PageLayout>
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{currentStep === 1 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step1TopicSupplier
selectedTopic={selectedTopic}
onTopicSelect={(t) => {
setSelectedTopic(t);
setSupplierName("");
setNewSup({ location: "", phone: "", contact: "" });
setIsOfficial(false);
setSupplierInvalid(false);
}}
supplierName={supplierName}
onSupplierName={(v) => {
setSupplierName(v);
setSupplierInvalid(false);
}}
newSup={newSup}
onNewSup={(p) => setNewSup((s) => ({ ...s, ...p }))}
isOfficial={isOfficial}
onOfficial={setIsOfficial}
invalid={supplierInvalid}
/>
</div>
)}
{currentStep === 2 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div>
)}
{currentStep === 3 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step3Termsheets
cards={cards}
onCardsChange={setCards}
items={items}
coverage={coverage}
onCoverageChange={(key, ids) => setCoverage((c) => ({ ...c, [key]: ids }))}
/>
</div>
)}
{currentStep === 4 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step4Projects
items={items}
projects={projectNames}
assignment={assignment}
onAssign={assign}
onUnassign={unassign}
onAddProject={(name) => {
void addProject.mutateAsync({ id: nextId("p", projects), name });
}}
/>
</div>
)}
{currentStep === 5 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step5Review
quoteDate={quoteDate}
onQuoteDate={setQuoteDate}
expiryDate={expiryDate}
onExpiryDate={setExpiryDate}
notes={notes}
onNotes={setNotes}
proforma={proforma}
onProforma={setProforma}
summary={review}
/>
</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>
{/* دیالوگ ذخیره با فیلدهای خالی */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>فیلدهای خالی</DialogTitle>
</DialogHeader>
<div className="text-[13.5px] leading-7">
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
{emptyFields.map((f) => (
<button
key={f.step}
type="button"
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
onClick={() => {
setConfirmOpen(false);
goToStep(f.step);
}}
>
{f.label}
</button>
))}
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="green"
onClick={() => {
setConfirmOpen(false);
void doSave();
}}
>
ذخیره کردن
</Button>
<Button
className="bg-red text-white hover:brightness-95"
onClick={() => {
setConfirmOpen(false);
goToStep(emptyFields[0]?.step ?? 1);
}}
>
تکمیل فیلد ها
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
</div>
);
}