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