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