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
+655
View File
@@ -0,0 +1,655 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
import { api, nextDashedId, nextId } from "../../lib/api";
import {
useMaterials,
useProjects,
useQuotes,
useSuppliers,
useUpdateQuote,
useAddSupplier,
useAddMaterial,
} from "../../hooks";
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
import { faNum } from "../../lib/formatters";
import { normalize } from "../../lib/normalize";
import { specsKey } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList";
import { MilestoneRow } from "../../components/MilestoneRow";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
interface EditItemRow {
key: string;
origLi?: LineItem;
cat: string;
brand: string;
specs: string[];
qty: number | null;
unit: string;
price: number | null;
total: number | null;
tsDomIds: string[];
acceptedTsId: string | null;
rejected: boolean;
restore: boolean;
}
interface EditTsCard {
key: string;
domId: string; // "" یا "new-ts-..." برای کارت جدید
milestones: Milestone[];
}
export interface EditModalProps {
quoteId: string | null;
onClose: () => void;
}
// مودال ویرایش کامل پیشنهاد
export function EditModal({ quoteId, onClose }: EditModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const updateQuote = useUpdateQuote();
const addSupplier = useAddSupplier();
const addMaterial = useAddMaterial();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const [ready, setReady] = useState(false);
const [supplierName, setSupplierName] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null);
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
const [status, setStatus] = useState("offered");
// گزینه‌های مشخصه — همه‌ی مشخصه‌های ثبت‌شده (کش)
const specOptions = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s)));
itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s)));
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials, itemRows]);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های خود پیشنهاد
const projectOptions = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name));
if (q) {
(q.projects || []).forEach((n) => set.add(n));
if (q.project) set.add(q.project);
}
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [projects, q]);
useMemo(() => {
if (!q) return;
const sup = supplierById(suppliers, q.supplier_id);
setSupplierName(sup.name);
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
setNotes(q.notes || "");
setItemRows(
q.line_items.map((li) => {
const m = materialById(materials, li.material_id);
return {
key: li.id,
origLi: li,
cat: m.category || "",
brand: m.brand || "",
specs: materialSpecs(m),
qty: li.quantity,
unit: li.unit || "",
price: li.unit_price,
total: li.total_price,
tsDomIds: itemTermsheetIds(li).filter((tid) => q.termsheets.some((t) => t.id === tid)),
acceptedTsId: li.accepted_ts_id,
rejected: !!li.rejected,
restore: true,
};
}),
);
setTsCards(
q.termsheets.map((t) => ({
key: t.id,
domId: t.id,
milestones: t.milestones.map((m) => ({ ...m })),
})),
);
setQuoteDate(q.quote_date || null);
setExpiryDate(q.expiry_date || null);
setAcceptedDate(q.accepted_date || null);
setArrivedDate(q.arrived_date || null);
setStatus(q.status);
setReady(true);
}, [q, suppliers, materials]);
// فاکتورها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => {
if (!quoteId) return;
let alive = true;
void api.files
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
return () => {
alive = false;
};
}, [quoteId]);
if (!q || !ready) return null;
const tsLabel = (card: EditTsCard, idx: number) => {
const orig = q.termsheets.find((t) => t.id === card.domId);
return orig?.label || `گزینه ${faNum(idx + 1)}`;
};
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const addItemRow = () => {
setItemRows((rows) => [
...rows,
{
key: "new-" + Math.random().toString(36).slice(2, 9),
cat: "",
brand: "",
specs: [],
qty: null,
unit: "",
price: null,
total: null,
tsDomIds: [],
acceptedTsId: null,
rejected: false,
restore: true,
},
]);
};
const updateTs = (key: string, patch: Partial<EditTsCard>) => {
setTsCards((cards) => cards.map((c) => (c.key === key ? { ...c, ...patch } : c)));
};
const addTsCard = () => {
setTsCards((cards) => [
...cards,
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
]);
};
const removeTsCard = (key: string) => {
setTsCards((cards) => cards.filter((c) => c.key !== key));
};
const confirm = async () => {
const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده — موجود یا جدید
if (supplierName.trim()) {
const norm = normalize(supplierName);
const exist = suppliers.find((s) => normalize(s.name) === norm);
if (exist) {
next.supplier_id = exist.id;
} else {
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
const created = await addSupplier.mutateAsync(newSup);
next.supplier_id = created.id; // UUID واقعی از بک‌اند
}
}
next.project = selectedProjects[0] || "";
next.projects = [...selectedProjects];
next.notes = notes.trim();
next.invoices = invoices;
// termsheetها — id موجود حفظ می‌شود، جدیدها ساخته می‌شوند
const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = [];
tsCards.forEach((card, i) => {
const domId = card.domId || "new-ts-" + i;
const realId =
domId && !domId.startsWith("new-ts-")
? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId;
const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered";
newTermsheets.push({ id: realId, label, milestones: card.milestones, status: st });
});
next.termsheets = newTermsheets;
// اقلام
const newItems: LineItem[] = [];
const materialsCopy = [...materials];
for (const row of itemRows) {
const cat = row.cat.trim();
const brand = row.brand.trim();
const specs = row.specs;
const unit = row.unit.trim();
const qty = row.qty;
const price = row.price;
const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
let material = materialsCopy.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 {
const nm = {
id: nextId("m", materialsCopy),
name: [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید",
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
category: cat,
brand,
specs,
base_unit: unit,
};
materialsCopy.push(nm);
// منتظر ساخت متریال می‌مانیم تا id واقعی (UUID) به قلم وصل شود
const created = await addMaterial.mutateAsync(nm);
materialId = created.id;
}
const wasRejected = row.rejected;
const rejected = wasRejected && !row.restore;
const termsheet_ids: string[] = [];
let acceptedTsId = row.acceptedTsId;
if (!rejected) {
row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId;
if (realId) termsheet_ids.push(realId);
});
if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id);
}
if (wasRejected) {
const acc = next.termsheets.find((t) => t.status === "accepted");
if (acc) {
termsheet_ids.length = 0;
termsheet_ids.push(acc.id);
}
acceptedTsId = acc ? acc.id : null;
}
} else {
acceptedTsId = null;
}
newItems.push({
id: "li-" + (newItems.length + 1),
material_id: materialId,
quantity: qty,
unit,
unit_price: price,
total_price: total,
termsheet_ids,
accepted_ts_id: acceptedTsId,
rejected,
arrived_date: row.origLi?.arrived_date ?? null,
projects: row.origLi?.projects ?? [],
});
}
next.line_items = newItems.length ? newItems : next.line_items;
// ترم‌شیت بدون قلم بی‌معنی است — حذف
const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null;
}
});
// تاریخ‌ها و وضعیت
next.quote_date = quoteDate || q.quote_date;
next.expiry_date = expiryDate || "";
next.accepted_date = acceptedDate;
next.arrived_date = arrivedDate;
next.status = status as Quote["status"];
// پاک‌سازی خودکار در بازگشت وضعیت
if (next.status === "offered") {
next.accepted_date = null;
next.arrived_date = null;
next.termsheets.forEach((t) => {
t.status = "offered";
});
next.line_items.forEach((li) => {
li.rejected = false;
li.arrived_date = null;
li.accepted_ts_id = null;
});
} else if (next.status === "accepted") {
next.arrived_date = null;
next.line_items.forEach((li) => {
li.arrived_date = null;
});
}
try {
await updateQuote.mutateAsync({ id: q.id, patch: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : "ذخیره‌ی تغییرات ناموفق بود");
return;
}
setReady(false);
onClose();
};
return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="field">
<label>تأمین‌کننده</label>
<Combobox
value={supplierName}
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
allowAdd
addSuffix="به عنوان تأمین‌کنندهٔ جدید"
placeholder="نام تأمین‌کننده"
onInputChange={(v) => setSupplierName(v)}
/>
</div>
<div className="field">
<label>پروژه</label>
<MultiSelectCombobox
values={selectedProjects}
onChange={setSelectedProjects}
options={projectOptions}
placeholder="انتخاب پروژه..."
/>
</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"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
{/* اقلام */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
<div className="flex flex-col gap-3">
{itemRows.map((row) => (
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="field">
<label>متریال</label>
<input
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="متریال"
value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/>
</div>
<div className="field">
<label>برند</label>
<input
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="برند"
value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
/>
</div>
<div className="field">
<label>مشخصه</label>
<MultiSelectCombobox
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="مشخصه"
/>
</div>
<div className="field">
<label>واحد</label>
<input
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="واحد"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
<div className="field">
<label>تعداد</label>
<NumberInput
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
qty,
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
});
}}
placeholder="تعداد"
/>
</div>
<div className="field">
<label>قیمت واحد</label>
<NumberInput
value={row.price}
onChange={(price) => {
patchItem(row.key, {
price,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
});
}}
placeholder="قیمت واحد"
maxDecimals={2}
/>
</div>
<div className="field">
<label>قیمت کل</label>
<NumberInput
value={row.total}
onChange={(t) => {
patchItem(row.key, {
total: t,
price:
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
});
}}
placeholder="قیمت کل"
/>
</div>
<div className="flex items-end justify-end">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
حذف
</button>
</div>
</div>
{/* تخصیص termsheet / بازگرداندن */}
<div className="mt-2.5 border-t border-line pt-2">
{row.rejected ? (
<label className="ts-cb-label e-restore">
<input
type="checkbox"
checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/>
بازگرداندن به معامله
</label>
) : (
<>
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, idx) => (
<label key={card.key} className="chip cursor-pointer">
<input
type="checkbox"
className="me-1 accent-accent"
checked={row.tsDomIds.includes(card.domId || card.key)}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
{tsLabel(card, idx)}
</label>
))}
</div>
</>
)}
</div>
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
+ افزودن قلم
</Button>
</div>
{/* termsheetها */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
<div className="flex flex-col gap-2">
{tsCards.map((card, idx) => (
<div key={card.key} className="variant-card">
<div className="variant-head">
<span className="variant-num">{tsLabel(card, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
×
</button>
</div>
<div className="mb-1 flex flex-col gap-1.5">
{card.milestones.map((ms, i) => (
<MilestoneRow
key={i}
milestone={ms}
onChange={(nextMs) =>
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
}
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
/>
))}
</div>
<Button
variant="line"
size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</Button>
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
+ افزودن termsheet
</Button>
</div>
{/* فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList
files={invoices}
onChange={setInvoices}
upload={(f) => api.files.upload(q.id, "invoice", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
/>
</div>
{/* تاریخ‌ها و وضعیت */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[12px] text-muted">تاریخ‌ها و وضعیت:</div>
<div className="grid grid-cols-2 gap-3">
<div className="field">
<label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
<div className="field col-span-2">
<label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}