Quotes that already have warehouse receipts can no longer be edited or rolled back: - backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.» - reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer) - frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote) - date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings Per-item target project (candidate projects stay intact): - expand_service exposes the decision target project (decision.project + line_item.target_project) - the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched - AcceptModal/EditModal select the target project per line item Material titles: - quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals Verified: python compile + api/frontend image builds (tsc + vite) clean.
446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
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,
|
||
formatMaterialTitle,
|
||
itemTermsheetIds,
|
||
materialById,
|
||
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";
|
||
|
||
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] = li.target_project?.name
|
||
? li.target_project.name
|
||
: names.length
|
||
? names[0]
|
||
: q.projects && q.projects.length
|
||
? q.projects[0]
|
||
: 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 name = itemProjectsSel[li.id];
|
||
if (name === undefined) return;
|
||
li.target_project = name ? { id: "", name } : null;
|
||
// توجه: li.projects دستنخورده باقی میماند تا پروژههای کاندیدای اولیه حفظ شوند
|
||
if (name) projUnion.add(name);
|
||
});
|
||
if (projUnion.size > 0) {
|
||
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);
|
||
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]">
|
||
{formatMaterialTitle(m)}
|
||
</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);
|
||
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">
|
||
{formatMaterialTitle(m)}
|
||
</span>
|
||
<span className="ms-2 text-[11px] text-muted">
|
||
{fmtNum(li.quantity)} {li.unit}
|
||
</span>
|
||
</div>
|
||
{projectOptions.length ? (
|
||
<select
|
||
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
||
value={itemProjectsSel[li.id] ?? ""}
|
||
onChange={(e) =>
|
||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||
}
|
||
>
|
||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
||
<option key={i} value="">
|
||
انتخاب پروژه...
|
||
</option>
|
||
))}
|
||
{projectOptions.map((n) => (
|
||
<option key={n} value={n}>
|
||
{n}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<div className="text-[12px] text-muted">پروژهای ثبت نشده است.</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* فاکتورهای اصلی */}
|
||
<div className="border-t border-line pt-3">
|
||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</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>
|
||
<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>
|
||
);
|
||
}
|