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
@@ -0,0 +1,438 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import type { Attachment, Milestone, Quote } from "../../lib/types";
import { api, nextDashedId } from "../../lib/api";
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
import {
activeItems,
itemTermsheetIds,
materialById,
materialSpecs,
specsLabel,
supplierById,
} from "../../lib/quoteUtils";
import { faNum, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { MilestoneRow } from "../../components/MilestoneRow";
import { FileList } from "../../components/FileList";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
interface CardDraft {
domId: string;
isNew: boolean;
milestones: Milestone[];
label: string | null; // null = پیش‌فرض «گزینه N»
}
export interface AcceptModalProps {
quoteId: string | null;
onClose: () => void;
}
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const updateQuote = useUpdateQuote();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const [cards, setCards] = useState<CardDraft[]>([]);
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({});
// تخصیص هر قلم به پروژه‌ها (نام‌ها)
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string[]>>({});
// تاریخ و زمان پذیرش — پیش‌فرض الان
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false);
// وقتی quote باز شد، state اولیه را بساز
useMemo(() => {
if (!q) return;
const offered = q.termsheets.filter((t) => t.status === "offered");
setCards(
offered.map((t) => ({ domId: t.id, isNew: false, milestones: JSON.parse(JSON.stringify(t.milestones)), label: t.label })),
);
const rem = new Set<string>();
q.line_items.forEach((li) => {
if (li.rejected) rem.add(li.id);
});
setRemovedItems(rem);
setAssignments({});
// پروژه‌های هر قلم — از تخصیص‌های قبلی، وگرنه پروژه‌های سطح پیشنهاد
const projSel: Record<string, string[]> = {};
q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
projSel[li.id] = names.length
? names
: q.projects && q.projects.length
? [...q.projects]
: q.project
? [q.project]
: [];
});
setItemProjectsSel(projSel);
setReady(true);
}, [q]);
// پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => {
if (!quoteId) return;
let alive = true;
void api.files
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => {
alive = false;
};
}, [quoteId]);
if (!q || !ready) return null;
const sup = supplierById(suppliers, q.supplier_id);
const allItems = q.line_items || [];
const active = activeItems(q);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها
const projectOptions = (() => {
const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name));
(q.projects || []).forEach((n) => set.add(n));
if (q.project) set.add(q.project);
allItems.forEach((li) => (li.projects || []).forEach((p) => p.name && set.add(p.name)));
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
})();
const cardLabel = (c: CardDraft, idx: number) => c.label || `گزینه ${faNum(idx + 1)}`;
const updateCard = (domId: string, patch: Partial<CardDraft>) => {
setCards((cs) => cs.map((c) => (c.domId === domId ? { ...c, ...patch } : c)));
};
const addNewCard = () => {
setCards((cs) => [...cs, { domId: "new-ts-" + Date.now(), isNew: true, milestones: [{ when_iso: null, amount: null }], label: null }]);
};
const removeCard = (domId: string) => {
setCards((cs) => cs.filter((c) => c.domId !== domId));
setAssignments((a) => {
const next: Record<string, string> = {};
Object.entries(a).forEach(([liId, dom]) => {
if (dom !== domId) next[liId] = dom;
});
return next;
});
};
const assign = (liId: string, domId: string) => {
setAssignments((a) => ({ ...a, [liId]: domId }));
};
const confirm = () => {
if (!q) return;
// پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store)
const next: Quote = JSON.parse(JSON.stringify(q));
// فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
cards.forEach((c, idx) => {
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
if (!milestones.length) return;
const existing = next.termsheets.find((t) => t.id === c.domId);
cardMeta.set(c.domId, {
milestones,
label: c.label || `گزینه ${faNum(idx + 1)}`,
existing,
});
});
if (!cardMeta.size) {
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
return;
}
// رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند
next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true;
});
const active = activeItems(next);
if (!active.length) {
toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
return;
}
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند
const fallbackDom = [...cardMeta.keys()][0];
const chosenDomIds = new Set<string>();
active.forEach((li) => {
const dom = assignments[li.id] || fallbackDom;
if (dom && cardMeta.has(dom)) chosenDomIds.add(dom);
});
const domToFinal: Record<string, string> = {};
chosenDomIds.forEach((dom) => {
const meta = cardMeta.get(dom)!;
if (meta.existing) {
meta.existing.milestones = meta.milestones;
meta.existing.status = "accepted";
domToFinal[dom] = dom;
} else {
const newId = nextDashedId("ts", next.termsheets);
next.termsheets.push({ id: newId, label: meta.label, milestones: meta.milestones, status: "accepted" });
domToFinal[dom] = newId;
}
});
// قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود
active.forEach((li) => {
const dom = assignments[li.id] || fallbackDom;
const finalId = domToFinal[dom];
const current = itemTermsheetIds(li).filter((tid) => next.termsheets.some((t) => t.id === tid));
if (finalId && !current.includes(finalId)) current.push(finalId);
li.termsheet_ids = current.length ? current : finalId ? [finalId] : current;
li.accepted_ts_id = finalId || null;
});
// termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered
const usedIds = new Set<string>();
active.forEach((li) => {
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
});
next.termsheets.forEach((t) => {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
// پروژه‌های هر قلم + همگام‌سازی پروژه‌های سطح پیشنهاد
const projUnion = new Set<string>();
next.line_items.forEach((li) => {
if (li.rejected) return;
const names = itemProjectsSel[li.id];
if (names === undefined) return;
li.projects = names.map((n) => ({ id: "", name: n }));
li.projects.forEach((p) => projUnion.add(p.name));
});
next.projects = [...projUnion];
next.project = next.projects[0] || "";
const now = new Date().toISOString();
next.accepted_date = acceptDate || now;
next.status = "accepted";
next.events.push({ type: "accepted", occurred_at: now });
next.invoices = invoices;
next.proformas = proformas;
void updateQuote.mutateAsync({ id: q.id, patch: next });
setReady(false);
onClose();
};
return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
</DialogHeader>
<div className="text-[12px] text-muted">
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
</div>
{/* کارت‌های termsheet */}
<div className="flex flex-col gap-2">
{cards.map((c, idx) => (
<div key={c.domId} className="modal-variant">
<div className="modal-variant-head">
<span className="variant-num">{cardLabel(c, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
×
</button>
</div>
<div className="flex flex-col gap-1.5">
{c.milestones.map((m, i) => (
<MilestoneRow
key={i}
milestone={m}
onChange={(nextM) => {
const ms = [...c.milestones];
ms[i] = nextM;
updateCard(c.domId, { milestones: ms });
}}
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
/>
))}
</div>
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت
</Button>
</div>
))}
</div>
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
+ نوشتن conditions جدید
</Button>
{/* تخصیص قلم‌ها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به conditions:</div>
<div className="mb-2 text-[11.5px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div>
<div>
{allItems.map((li) => {
const removed = removedItems.has(li.id);
const m = materialById(materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return (
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
<input
type="checkbox"
className="accent-accent"
checked={!removed}
title="تیک = در معامله می‌ماند"
onChange={(e) => {
const next = new Set(removedItems);
if (e.target.checked) next.delete(li.id);
else next.add(li.id);
setRemovedItems(next);
}}
/>
<span className="flex-1 text-[13px]">
{m.category} {m.brand}
{specText ? ` · ${specText}` : ""}
</span>
<span className="text-[12px] text-muted">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
</span>
<select
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => assign(li.id, e.target.value)}
>
{cards.map((c, idx) => (
<option key={c.domId} value={c.domId}>
{cardLabel(c, idx)}
</option>
))}
</select>
</div>
);
})}
{active.length === 0 && (
<div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div>
)}
</div>
</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">
برای هر متریال مشخص کنید به کدام پروژه (ها) تعلق می‌گیرد.
</div>
<div className="flex flex-col gap-2">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return (
<div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]">
<span className="font-semibold">
{m.category} {m.brand}
</span>
{specText ? <span className="text-muted"> · {specText}</span> : ""}
<span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit}
</span>
</div>
<MultiSelectCombobox
values={itemProjectsSel[li.id] || []}
onChange={(names) => setItemProjectsSel((s) => ({ ...s, [li.id]: names }))}
options={projectOptions}
placeholder="انتخاب پروژه..."
/>
</div>
);
})}
</div>
</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-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
پیش‌فاکتورهای دریافتی از تأمین‌کننده؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
label="افزودن پیش‌فاکتور"
/>
</div>
{/* تاریخ و زمان پذیرش */}
<div className="border-t border-line pt-3">
<div className="field max-w-xs">
<label>تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div>
</div>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={confirm}>ثبت معامله بسته‌شده</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,132 @@
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Package } from "lucide-react";
import type { Quote } from "../../lib/types";
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
export interface ArriveModalProps {
quoteId: string | null;
onClose: () => void;
}
// مودال رسید: تیک بزنید کدام قلم‌ها رسیده‌اند؛ تا همه نرسیده باشند quote پذیرفته می‌ماند
export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const updateQuote = useUpdateQuote();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const [checked, setChecked] = useState<Set<string>>(new Set());
// تاریخ و زمان رسید — پیش‌فرض الان
const [arriveDate, setArriveDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false);
useMemo(() => {
if (!q) return;
const set = new Set<string>();
activeItems(q).forEach((li) => {
if (li.arrived_date) set.add(li.id);
});
setChecked(set);
setReady(true);
}, [q]);
if (!q || !ready) return null;
const sup = supplierById(suppliers, q.supplier_id);
const items = activeItems(q);
const confirm = () => {
if (!checked.size) {
toast.error("حداقل یک قلم را برای رسید انتخاب کنید.");
return;
}
const next: Quote = JSON.parse(JSON.stringify(q));
const arrivedIso = arriveDate || new Date().toISOString();
const now = new Date().toISOString();
next.line_items.forEach((li) => {
li.arrived_date = checked.has(li.id) ? arrivedIso : null;
});
if (activeItems(next).every((li) => li.arrived_date)) {
next.status = "arrived";
next.arrived_date = arrivedIso;
next.events.push({ type: "arrived", occurred_at: now });
} else {
next.arrived_date = null;
}
void updateQuote.mutateAsync({ id: q.id, patch: next });
setReady(false);
onClose();
};
return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ثبت رسید — {sup.name}</DialogTitle>
</DialogHeader>
<div className="text-[12px] text-muted">
کدام قلم‌ها رسیده‌اند؟ تیک بزنید و ثبت کنید. تا وقتی همه‌ی قلم‌ها نرسیده‌اند، پیشنهاد
«پذیرفته شده» می‌ماند.
</div>
<div className="flex flex-col gap-1.5">
{items.map((li) => {
const m = materialById(materials, li.material_id);
const specText = materialSpecs(m).join(" · ");
return (
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
<input
type="checkbox"
className="accent-green"
checked={checked.has(li.id)}
onChange={(e) => {
const next = new Set(checked);
if (e.target.checked) next.add(li.id);
else next.delete(li.id);
setChecked(next);
}}
/>
<span>
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
{m.category} {m.brand}
{specText ? ` · ${specText}` : ""}
</span>
<span className="arrive-meta">
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
</span>
</label>
);
})}
</div>
{/* تاریخ و زمان رسید */}
<div className="field max-w-xs">
<label>تاریخ و زمان رسید</label>
<JalaliDatePicker value={arriveDate} onChange={setArriveDate} placeholder="تاریخ و ساعت" withTime />
</div>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button variant="green" onClick={confirm}>
ثبت رسید
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,143 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import {
useMaterials,
useProjects,
useQuotes,
useStoreData,
useSuppliers,
useUnits,
} from "../../hooks";
import { useAppStore } from "../../store";
import { filterQuotes } from "../../lib/quoteUtils";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { StatsBar } from "../../components/StatsBar";
import { FilterToolbar, type Filters } from "./FilterToolbar";
import { LedgerTable } from "./LedgerTable";
import { PanelMenu } from "../../components/PanelMenu";
import { useLogout } from "../Inventory/panelMenu";
import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
// صفحه‌ی اصلی دفتر پیشنهادهای خرید
export function DashboardPage() {
useStoreData();
const logout = useLogout();
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const { categoryUnits, unitConversions, standardConversions } = useUnits();
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [acceptId, setAcceptId] = useState<string | null>(null);
const [editId, setEditId] = useState<string | null>(null);
const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions],
);
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند
const filtered = useMemo(() => {
const list = filterQuotes(store, filters);
const createdTs = (q: (typeof store.quotes)[number]) => {
const iso = q.created_at || q.quote_date;
const ts = iso ? new Date(iso).getTime() : 0;
return Number.isFinite(ts) ? ts : 0;
};
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
}, [store, filters, sortDir]);
const stats = useMemo(() => {
const total = quotes.length;
const offered = quotes.filter((q) => q.status === "offered").length;
const accepted = quotes.filter((q) => q.status === "accepted").length;
const leadTimes = quotes
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
return { total, offered, accepted, avgLead };
}, [quotes]);
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return (
<div className="min-h-screen">
<Header
title="دفتر پیشنهادهای خرید"
sub="داده‌ها در پایگاه داده ذخیره می‌شوند"
brand
menu={
<PanelMenu
title="پنل‌ها"
items={[
{ label: "مدیریت انبار", to: "/inventory" },
{ label: "مدیریت تدارکات", to: "/", active: true },
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
]}
/>
}
actions={
<>
<Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها
</Link>
<Link to="/projects" className="btn-line-file no-underline">
<FolderKanban className="h-3.5 w-3.5" />
پروژه‌ها
</Link>
<Link to="/units" className="btn-line-file no-underline">
<Ruler className="h-3.5 w-3.5" />
ضرایب تبدیل
</Link>
<Link to="/wizard" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark">
<Plus className="h-3.5 w-3.5" />
افزودن پیشنهاد جدید
</Link>
</>
}
/>
<PageLayout className="flex flex-col gap-4">
<StatsBar
total={stats.total}
offered={stats.offered}
accepted={stats.accepted}
avgLeadTime={stats.avgLead}
/>
<FilterToolbar
filters={filters}
onChange={setFilters}
categories={categoryOptions}
suppliers={supplierOptions}
sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
/>
<LedgerTable
store={store}
quotes={filtered}
expanded={expandedQuoteIds}
onToggleExpand={toggleExpand}
onAccept={setAcceptId}
onEdit={setEditId}
/>
</PageLayout>
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
</div>
);
}
+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>
);
}
@@ -0,0 +1,82 @@
import { Combobox } from "../../components/Combobox";
export interface Filters {
category: string;
supplier: string;
status: string;
search: string;
}
export interface FilterToolbarProps {
filters: Filters;
onChange: (f: Filters) => void;
categories: string[];
suppliers: { id: string; name: string }[];
sortDir: "desc" | "asc";
onToggleSort: () => void;
}
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمه‌ی مرتب‌سازی ⇅
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) {
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
return (
<div className="flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field min-w-[170px] flex-1">
<label>تأمین‌کننده</label>
<Combobox
value={filters.supplier}
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
items={suppliers}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ supplier: v })}
/>
</div>
<div className="field min-w-[150px] flex-1">
<label>وضعیت</label>
<Combobox
value={filters.status}
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={["پیشنهاد شده", "پذیرفته شده", "رسیده"]}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ status: v })}
/>
</div>
<div className="field min-w-[200px] flex-1">
<label>جستجو</label>
<input
type="search"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="جستجو در نام/برند/پروژه..."
value={filters.search}
onChange={(e) => set({ search: e.target.value })}
/>
</div>
<div className="field min-w-[140px]">
<label>ترتیب</label>
<button
type="button"
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm hover:bg-slate-bg"
onClick={onToggleSort}
title={sortDir === "desc" ? "تغییر به قدیمی‌ترین اول" : "تغییر به جدیدترین اول"}
>
<span aria-hidden className="text-accent">⇅</span>
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمی‌ترین اول"}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { useCallback, useLayoutEffect, useState } from "react";
export interface FlowRefs {
items: Map<string, HTMLDivElement>;
termsheets: Map<string, HTMLDivElement>;
}
export interface FlowDiagramProps {
flowEl: HTMLDivElement | null;
refs: FlowRefs;
redrawKey?: string; // وقتی داده‌ی quote عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
}
interface PathSpec {
d: string;
accepted: boolean;
}
// خطوط اتصال SVG بین جعبه‌های اقلام و ترم‌شیت‌ها — موقعیت جعبه‌ها با
// getBoundingClientRect خوانده می‌شود و با ResizeObserver در جریان می‌ماند.
// خاکستری = باز، آبی = جفت پذیرفته‌شده‌ی دقیق.
export function FlowDiagram({ flowEl, refs, redrawKey = "" }: FlowDiagramProps) {
const [paths, setPaths] = useState<PathSpec[]>([]);
const draw = useCallback(() => {
if (!flowEl) return;
const crect = flowEl.getBoundingClientRect();
if (!crect.width) return;
const out: PathSpec[] = [];
refs.items.forEach((item) => {
const ir = item.getBoundingClientRect();
const x1 = ir.left - crect.left; // لبه‌ی چپ جعبه‌ی قلم (رو به فاصله)
const y1 = ir.top + ir.height / 2 - crect.top;
const isRejected = item.classList.contains("rejected");
const acceptedTsId = item.dataset.acceptedTs || "";
const tsIds = (item.dataset.tsIds || "").split(",").filter(Boolean);
tsIds.forEach((tsId) => {
const t = refs.termsheets.get(tsId);
if (!t) return;
const tr = t.getBoundingClientRect();
const x2 = tr.right - crect.left; // لبه‌ی راست جعبه‌ی termsheet
const y2 = tr.top + tr.height / 2 - crect.top;
const cx = (x1 + x2) / 2;
// آبی فقط برای جفت دقیقی که قلم پذیرفته — بقیه خاکستری
const accepted = !isRejected && acceptedTsId === tsId;
out.push({
d: `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`,
accepted,
});
});
});
setPaths(out);
}, [flowEl, refs, redrawKey]);
useLayoutEffect(() => {
draw();
if (!flowEl) return;
const ro = new ResizeObserver(draw);
ro.observe(flowEl);
window.addEventListener("resize", draw);
if (typeof document !== "undefined" && document.fonts && document.fonts.ready) {
void document.fonts.ready.then(draw);
}
return () => {
ro.disconnect();
window.removeEventListener("resize", draw);
};
}, [draw, flowEl]);
return (
<svg className="flow-lines" aria-hidden="true">
{paths.map((p, i) => (
<path key={i} className={`flow-line${p.accepted ? " accepted" : ""}`} d={p.d} />
))}
</svg>
);
}
@@ -0,0 +1,50 @@
import { Inbox } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import { QuoteCard } from "./QuoteCard";
import { EmptyState } from "../../components/EmptyState";
export interface LedgerTableProps {
store: Store;
quotes: Quote[];
expanded: Set<string>;
onToggleExpand: (id: string) => void;
onAccept: (id: string) => void;
onEdit: (id: string) => void;
}
// دفتر پیشنهادها — فهرست کارت‌ها (هر پیشنهاد یک کارت خوانا)
export function LedgerTable({
store,
quotes,
expanded,
onToggleExpand,
onAccept,
onEdit,
}: LedgerTableProps) {
if (!quotes.length) {
return (
<div className="rounded-lg border border-line bg-panel">
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
</div>
);
}
return (
<div className="flex flex-col gap-3">
{quotes.map((q) => (
<QuoteCard
key={q.id}
store={store}
q={q}
expanded={expanded.has(q.id)}
onToggleExpand={() => onToggleExpand(q.id)}
onAccept={() => onAccept(q.id)}
onEdit={() => onEdit(q.id)}
/>
))}
<div className="py-1 text-center text-[11px] text-muted">
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیش‌بینی قیمت، شمارش نمی‌شود.
</div>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
itemsSummary,
materialById,
materialSpecs,
projectCell,
quoteTotal,
supplierById,
termsheetSummary,
unitWarning,
} from "../../lib/quoteUtils";
import { STATUS_LABEL } from "../../lib/constants";
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
import { Badge } from "../../components/ui/badge";
import { cn } from "../../lib/utils";
import { QuoteDetail } from "./QuoteDetail";
export interface QuoteCardProps {
store: Store;
q: Quote;
expanded: boolean;
onToggleExpand: () => void;
onAccept: () => void;
onEdit: () => void;
}
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0">
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
<div className="min-w-0 text-[13px] leading-6">{children}</div>
</div>
);
}
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
const total = quoteTotal(q);
const pc = priceCell(total);
const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون پذیرش (با ساعت)
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
<button className="chip chip-act" onClick={onAccept}>
پذیرش
</button>
);
} else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
} else {
acceptedCell = <span className="text-muted">—</span>;
}
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود؛
// اینجا فقط وضعیت نمایش داده می‌شود.
let arrivedCell: React.ReactNode;
if (q.status === "accepted") {
if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
} else {
const items = (q.line_items || []).filter((li) => !li.rejected);
const arrivedCount = items.filter((li) => li.arrived_date).length;
arrivedCell = arrivedCount ? (
<span className="tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
) : (
<span className="text-muted">در انتظار PM</span>
);
}
} else if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
} else {
arrivedCell = <span className="text-muted">—</span>;
}
return (
<div
className={cn(
"overflow-hidden rounded-lg border bg-panel transition-colors",
expired ? "border-amber/40" : "border-line hover:border-accent/40",
expanded && "border-accent/60",
)}
>
{/* سربرگ کارت */}
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-sm font-bold text-accent-dark">
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0">
<Link
to={`/suppliers/${q.supplier_id}`}
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
>
{sup.name}
</Link>
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
</div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
<Pencil className="h-3 w-3" />
ویرایش
</button>
<button
className="chip chip-expand"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جریان"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
</button>
</div>
</div>
{/* اطلاعات کلیدی */}
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
<InfoCell label="اقلام">
{summary.count > 1 ? (
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span>
);
})}
</div>
</div>
) : (
summary.text
)}
{unitWarns.map((w) => (
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
className="unit-warn mt-1"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
<TriangleAlert className="h-2.5 w-2.5" />
واحد جدید: {w.unit}
</Link>
))}
</InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
{q.projects && q.projects.length > 0 ? (
<span className="flex flex-col gap-0.5">
{q.projects.map((p, i) => (
<span key={i}>{p}</span>
))}
</span>
) : (
projectCell(q)
)}
</span>
</InfoCell>
<InfoCell label="مجموع">
{pc ? (
<span className="font-bold tabular-nums">
{pc.num} <span className="num-words">تومان</span> <span className="num-words">({pc.words})</span>
</span>
) : (
"—"
)}
</InfoCell>
<InfoCell label="معامله">
{ts.title !== "—" ? (
<div>
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
{ts.title}
</div>
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
</div>
) : (
<span className="text-muted">—</span>
)}
</InfoCell>
</div>
{/* تاریخ‌ها و اقدامات */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-2 text-[11.5px] text-muted">
<span className="inline-flex items-center gap-1.5">
<Package className="h-3 w-3" />
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
</span>
<span className="inline-flex items-center gap-1.5">
<CreditCard className="h-3 w-3" />
معتبر تا:{" "}
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)}
</b>
{expired && <AlarmClock className="h-3 w-3 text-amber" aria-label="منقضی" />}
</span>
<span className="inline-flex items-center gap-1.5">
پذیرش: {acceptedCell}
</span>
<span className="inline-flex items-center gap-1.5">
رسید: {arrivedCell}
</span>
</div>
{/* جریان (expand) */}
{expanded && <QuoteDetail store={store} q={q} />}
</div>
);
}
@@ -0,0 +1,212 @@
import { useEffect, useState } from "react";
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api";
import {
itemTermsheetIds,
lineItemTotal,
materialById,
materialSpecs,
refConverted,
specsLabel,
} from "../../lib/quoteUtils";
import { fmtDate, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils";
export interface QuoteDetailProps {
store: Store;
q: Quote;
}
// بخش expanded یک پیشنهاد: اقلام (راست) ↔ ترم‌شیت‌ها (چپ) با خطوط SVG رنگی
export function QuoteDetail({ store, q }: QuoteDetailProps) {
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
const [bump, setBump] = useState(0);
useEffect(() => {
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
const t = setTimeout(() => setBump((b) => b + 1), 350);
return () => clearTimeout(t);
}, []);
void bump;
// پیوست‌ها (فاکتور/پیش‌فاکتور) مستقیماً از سرور (MinIO) خوانده می‌شوند
const [attachments, setAttachments] = useState<{ invoices: Attachment[]; proformas: Attachment[] }>({
invoices: [],
proformas: [],
});
useEffect(() => {
let alive = true;
void Promise.all([
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
]).then(([invoices, proformas]) => {
if (alive) setAttachments({ invoices, proformas });
});
return () => {
alive = false;
};
}, [q.id]);
const tsList = q.termsheets || [];
const allItems = q.line_items || [];
const items = allItems.filter((li) => !li.rejected);
const rejectedItems = allItems.filter((li) => li.rejected);
// وقتی داده‌ی جریان عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
const redrawKey = JSON.stringify({
items: allItems.map((li) => [li.id, li.accepted_ts_id, !!li.rejected, !!li.arrived_date]),
ts: tsList.map((t) => [t.id, t.status]),
});
const itemBox = (li: (typeof allItems)[number], isRejected: boolean) => {
const m = materialById(store.materials, li.material_id);
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
const total = lineItemTotal(li);
const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
const specText = specsLabel(materialSpecs(m));
return (
<div
key={li.id}
ref={(el) => {
if (el) {
flowRefs.items.set(li.id, el);
el.dataset.tsIds = tsIds.join(",");
el.dataset.acceptedTs = li.accepted_ts_id || "";
} else {
flowRefs.items.delete(li.id);
}
}}
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}`}
>
<div className="fb-name">
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
{m.category} {m.brand}
{specText ? ` · ${specText}` : ""}
</div>
<div className="fb-meta">
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
</div>
<div className="fb-nums">
<span>
{fmtNum(li.quantity)} {li.unit || ""}
</span>
<span>× {groupCommas(li.unit_price, true)}</span>
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
</div>
{conv && (
<div className="fb-conv">
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
</div>
)}
</div>
);
};
const attachParts: React.ReactNode[] = [];
attachments.proformas.forEach((pf, i) => {
if (!pf.id) return;
attachParts.push(
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
<Paperclip className="me-1 inline h-3 w-3" />
پیش‌فاکتور: {pf.name}
</a>,
);
});
attachments.invoices.forEach((inv, i) => {
if (!inv.id) return;
attachParts.push(
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
<Paperclip className="me-1 inline h-3 w-3" />
فاکتور اصلی: {inv.name}
</a>,
);
});
return (
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
<div className="flow-cols">
<div className="flow-col flow-items-col">
<div className="flow-col-h">
<Package className="h-3.5 w-3.5" /> اقلام ({fmtNum(items.length)})
</div>
{items.length ? items.map((li) => itemBox(li, false)) : <div className="flow-note">—</div>}
{rejectedItems.length > 0 && (
<>
<div className="flow-rejected-h">
<Ban className="h-3 w-3" /> قلم‌های رد شده (از معامله خارج‌اند)
</div>
{rejectedItems.map((li) => itemBox(li, true))}
<div className="flow-rejected-hint">
با بازگشت وضعیت به مرحله‌ی پیشین (یا ویرایش)، این قلم‌ها برمی‌گردند.
</div>
</>
)}
</div>
<div className="flow-col flow-terms-col">
<div className="flow-col-h">
<CreditCard className="h-3.5 w-3.5" /> شرایط پرداخت ({fmtNum(tsList.length)})
</div>
{tsList.length ? (
tsList.map((t) => {
const isAccepted = t.status === "accepted";
const arrived = q.status === "arrived" && isAccepted;
const badge = arrived ? (
<span className="fb-badge arrived">رسیده</span>
) : isAccepted ? (
<span className="fb-badge accepted">✓ پذیرفته</span>
) : (
<span className="fb-badge offered">پیشنهاد</span>
);
return (
<div
key={t.id}
ref={(el) => {
if (el) flowRefs.termsheets.set(t.id, el);
else flowRefs.termsheets.delete(t.id);
}}
className={`flow-box flow-ts${isAccepted ? (arrived ? " ts-arrived" : " ts-accepted") : ""}`}
>
<div className="fb-name">
<CreditCard className="h-3.5 w-3.5 text-muted" />
{t.label} {badge}
</div>
<div className="fb-ms-list">
{(t.milestones || []).length ? (
t.milestones.map((m, i) => (
<div key={i} className="fb-ms">
<CalendarDays className="h-3 w-3" />
{m.when_iso ? fmtDate(m.when_iso) : "—"} ·{" "}
{m.amount != null ? groupCommas(m.amount, true) + " تومان" : "—"}
</div>
))
) : (
<div className="fb-ms">—</div>
)}
</div>
</div>
);
})
) : (
<div className="flow-note">—</div>
)}
</div>
</div>
<FlowDiagram flowEl={flowEl} refs={flowRefs} redrawKey={redrawKey} />
{attachParts.length > 0 && (
<div className="flow-attach">
{attachParts.map((p, i) => (
<span key={i} className="detail-item">
{p}
</span>
))}
</div>
)}
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
itemsSummary,
materialById,
materialSpecs,
projectCell,
quoteTotal,
supplierById,
termsheetSummary,
unitWarning,
} from "../../lib/quoteUtils";
import { STATUS_LABEL } from "../../lib/constants";
import { esc, fmtDate, fmtNum, priceCell } from "../../lib/formatters";
import { Badge } from "../../components/ui/badge";
import { cn } from "../../lib/utils";
export interface QuoteRowProps {
store: Store;
q: Quote;
expanded: boolean;
onToggleExpand: () => void;
onAccept: () => void;
onEdit: () => void;
}
// یک ردیف جمع‌شده‌ی پیشنهاد در دفتر
export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteRowProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
const total = quoteTotal(q);
const pc = priceCell(total);
const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
// ستون پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
<button className="chip chip-act" onClick={onAccept}>
پذیرش
</button>
);
} else if (q.accepted_date) {
acceptedCell = <span className="text-xs tabular-nums">{fmtDate(q.accepted_date)}</span>;
} else {
acceptedCell = <span className="text-xs text-muted">—</span>;
}
// ستون رسید
let arrivedCell: React.ReactNode;
if (q.status === "accepted") {
if (q.arrived_date) {
arrivedCell = <span className="text-xs tabular-nums">{fmtDate(q.arrived_date)}</span>;
} else {
const items = (q.line_items || []).filter((li) => !li.rejected);
const arrivedCount = items.filter((li) => li.arrived_date).length;
// v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود.
arrivedCell = arrivedCount ? (
<span className="text-xs tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
) : (
<span className="text-xs text-muted">در انتظار PM</span>
);
}
} else if (q.arrived_date) {
arrivedCell = <span className="text-xs tabular-nums">{fmtDate(q.arrived_date)}</span>;
} else {
arrivedCell = <span className="text-xs text-muted">—</span>;
}
const summary = itemsSummary(store, q);
return (
<tr className={cn("quote-row transition-colors", expired && "stale-row", expanded && "bg-accent-bg/40")}>
{/* تأمین‌کننده */}
<td>
<Link to={`/suppliers/${q.supplier_id}`} className="text-[13px] font-semibold text-accent no-underline hover:underline">
{sup.name}
</Link>
{sup.location && <div className="text-[11px] text-muted">{sup.location}</div>}
</td>
{/* اقلام */}
<td>
<div className="text-[13px] leading-5">
{summary.count > 1 ? (
<div className="flex flex-col gap-0.5">
<b>{fmtNum(summary.count)} قلم</b>
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span>
);
})}
</div>
</div>
) : (
summary.text
)}
</div>
{unitWarns.map((w) => (
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
className="unit-warn"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
<TriangleAlert className="h-2.5 w-2.5" />
واحد جدید: {w.unit}
</Link>
))}
</td>
{/* پروژه */}
<td className="text-[13px]">{projectCell(q)}</td>
{/* مجموع */}
<td className="text-[13px] font-semibold tabular-nums whitespace-nowrap">
{pc ? (
<>
{pc.num} <span className="num-words">({pc.words})</span>
</>
) : (
"—"
)}
</td>
{/* معامله */}
<td>
{ts.title !== "—" ? (
<div>
<div className="text-[12px] font-semibold leading-5" style={ts.color ? { color: ts.color } : undefined}>
{ts.title}
</div>
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
</div>
) : (
<span className="text-xs text-muted">—</span>
)}
</td>
{/* تاریخ‌ها */}
<td className="text-xs tabular-nums whitespace-nowrap">{fmtDate(q.quote_date)}</td>
<td className="text-xs tabular-nums whitespace-nowrap">
{fmtDate(q.expiry_date)}
{expired && <AlarmClock className="ms-1 inline h-3 w-3 text-amber" aria-label="منقضی" />}
</td>
<td>{acceptedCell}</td>
<td>{arrivedCell}</td>
{/* وضعیت */}
<td>
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
</td>
{/* عملیات */}
<td>
<div className="flex items-center gap-1">
<button className="chip" onClick={onEdit}>
ویرایش
</button>
<button
className="chip chip-expand"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جریان"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180")} />
</button>
</div>
</td>
</tr>
);
}