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