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
+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>
);
}