feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user