Files
ERP/frontend/src/pages/Dashboard/AcceptModal.tsx
T

465 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
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]);
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;
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("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
return;
}
next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true;
});
const active = activeItems(next);
if (!active.length) {
toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
return;
}
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;
}
});
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;
});
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";
});
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
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;
});
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-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
<div className="flex flex-col max-h-[85vh] w-full">
{/* هدر ثابت و هم‌تراز */}
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-right text-lg font-bold text-slate-800">
پذیرش پیشنهاد — {sup.name}
</DialogTitle>
<span className="font-mono text-xs font-bold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-lg border border-line shrink-0 select-all">
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
</span>
</div>
</DialogHeader>
<p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
</p>
</div>
{/* بدنه اسکرول‌پذیر */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
{/* بخش ۱: تنظیم شرایط پرداخت */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
<div className="flex flex-col gap-4">
{cards.map((c, idx) => (
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
<div className="modal-variant-head mb-2 flex items-center justify-between">
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
×
</button>
</div>
<div className="flex flex-col gap-2">
{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="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت جدید
</Button>
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
+ افزودن شرایط پرداخت جدید
</Button>
</section>
{/* بخش ۲: تخصیص اقلام */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
<div className="mb-4 text-[12px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div>
<div className="flex flex-col gap-2">
{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 flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
>
<input
type="checkbox"
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
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);
}}
/>
<div className="flex-1 min-w-[120px]">
<div className="text-[13.5px] font-medium text-slate-800">
{formatMaterialTitle(m)}
</div>
<div className="text-[12px] text-muted mt-0.5">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
</div>
</div>
<select
className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => assign(li.id, e.target.value)}
disabled={removed}
>
{cards.map((c, idx) => (
<option key={c.domId} value={c.domId}>
{cardLabel(c, idx)}
</option>
))}
</select>
</div>
);
})}
{active.length === 0 && (
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
<span>⚠️</span> همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
</div>
)}
</div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-4 text-[12px] text-muted">
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{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-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)}
</span>
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit}
</span>
</div>
{projectOptions.length ? (
<select
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
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 bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)}
</div>
);
})}
</div>
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<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 : "حذف فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/>
</div>
<div className="h-px bg-line w-full"></div>
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<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 : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
<div className="h-px bg-line w-full"></div>
<div className="max-w-xs">
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div>
</section>
</div>
{/* فوتر ثابت */}
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف
</Button>
<Button onClick={confirm} className="w-full sm:w-auto">
ثبت معامله بسته‌شده
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
}