832 lines
37 KiB
TypeScript
832 lines
37 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";
|
||
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||
|
||
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
|
||
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||
const TEENS = [
|
||
"ده",
|
||
"یازده",
|
||
"دوازده",
|
||
"سیزده",
|
||
"چهارده",
|
||
"پانزده",
|
||
"شانزده",
|
||
"هفده",
|
||
"هجده",
|
||
"نوزده",
|
||
];
|
||
const TENS = [
|
||
"",
|
||
"",
|
||
"بیست",
|
||
"سی",
|
||
"چهل",
|
||
"پنجاه",
|
||
"شصت",
|
||
"هفتاد",
|
||
"هشتاد",
|
||
"نود",
|
||
];
|
||
const HUNDREDS = [
|
||
"",
|
||
"صد",
|
||
"دویست",
|
||
"سیصد",
|
||
"چهارصد",
|
||
"پانصد",
|
||
"ششصد",
|
||
"هفتصد",
|
||
"هشتصد",
|
||
"نهصد",
|
||
];
|
||
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
|
||
const DECIMAL_SCALES: Record<number, string> = {
|
||
1: "دهم",
|
||
2: "صدم",
|
||
3: "هزارم",
|
||
4: "دههزارم",
|
||
};
|
||
|
||
function convertThreeDigits(n: number): string {
|
||
if (n === 0) return "";
|
||
const parts: string[] = [];
|
||
const h = Math.floor(n / 100);
|
||
const rem = n % 100;
|
||
if (h > 0) parts.push(HUNDREDS[h]);
|
||
if (rem >= 10 && rem <= 19) {
|
||
parts.push(TEENS[rem - 10]);
|
||
} else {
|
||
const t = Math.floor(rem / 10);
|
||
const u = rem % 10;
|
||
if (t > 0) parts.push(TENS[t]);
|
||
if (u > 0) parts.push(ONES[u]);
|
||
}
|
||
return parts.join(" و ");
|
||
}
|
||
|
||
function toEnglishDigits(str: string): string {
|
||
return str
|
||
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
|
||
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
|
||
}
|
||
|
||
function numberToPersianWords(val: number | string | null | undefined): string {
|
||
if (val == null || val === "") return "";
|
||
const numStr = toEnglishDigits(String(val).trim());
|
||
const num = parseFloat(numStr);
|
||
if (isNaN(num)) return "";
|
||
if (num === 0) return "صفر";
|
||
|
||
const isNeg = num < 0;
|
||
const parts = Math.abs(num).toString().split(".");
|
||
const intStr = parts[0];
|
||
const decStr = parts[1];
|
||
|
||
let intNum = parseInt(intStr, 10);
|
||
let intResult = "";
|
||
|
||
if (intNum === 0) {
|
||
intResult = "صفر";
|
||
} else {
|
||
const chunks: number[] = [];
|
||
while (intNum > 0) {
|
||
chunks.push(intNum % 1000);
|
||
intNum = Math.floor(intNum / 1000);
|
||
}
|
||
const chunkWords: string[] = [];
|
||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||
const c = chunks[i];
|
||
if (c > 0) {
|
||
const w = convertThreeDigits(c);
|
||
const s = SCALES[i];
|
||
chunkWords.push(s ? `${w} ${s}` : w);
|
||
}
|
||
}
|
||
intResult = chunkWords.join(" و ");
|
||
}
|
||
|
||
if (decStr && parseInt(decStr, 10) > 0) {
|
||
const cleanDecStr = decStr.slice(0, 4);
|
||
const decNum = parseInt(cleanDecStr, 10);
|
||
const decWord = numberToPersianWords(decNum);
|
||
const scale = DECIMAL_SCALES[cleanDecStr.length];
|
||
if (scale) {
|
||
if (intNum === 0) {
|
||
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
|
||
}
|
||
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
|
||
}
|
||
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
|
||
}
|
||
|
||
return isNeg ? `منفی ${intResult}` : intResult;
|
||
}
|
||
|
||
interface CardDraft {
|
||
domId: string;
|
||
isNew: boolean;
|
||
milestones: Milestone[];
|
||
label: string | null;
|
||
}
|
||
|
||
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 [itemAllocations, setItemAllocations] = useState<
|
||
Record<string, { rowId: string; project_name: string; quantity: number | 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> = {};
|
||
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
|
||
q.line_items.forEach((li) => {
|
||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||
const defaultProj = li.target_project?.name
|
||
? li.target_project.name
|
||
: names.length
|
||
? names[0]
|
||
: q.projects && q.projects.length
|
||
? q.projects[0]
|
||
: q.project || "";
|
||
projSel[li.id] = defaultProj;
|
||
if (li.allocations && li.allocations.length) {
|
||
allocs[li.id] = li.allocations.map((a, idx) => ({
|
||
rowId: `alloc-${li.id}-${idx}`,
|
||
project_name: a.project_name,
|
||
quantity: a.quantity,
|
||
}));
|
||
} else {
|
||
allocs[li.id] = [
|
||
{
|
||
rowId: `alloc-${li.id}-0`,
|
||
project_name: defaultProj,
|
||
quantity: li.quantity ?? 1,
|
||
},
|
||
];
|
||
}
|
||
});
|
||
setItemProjectsSel(projSel);
|
||
setItemAllocations(allocs);
|
||
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 addAllocationRow = (liId: string, totalQty: number) => {
|
||
setItemAllocations((prev) => {
|
||
const currentList = prev[liId] || [];
|
||
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
|
||
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
|
||
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
|
||
const newRow = {
|
||
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||
project_name: firstAvailable,
|
||
quantity: remaining > 0 ? remaining : "",
|
||
};
|
||
return { ...prev, [liId]: [...currentList, newRow] };
|
||
});
|
||
};
|
||
|
||
const removeAllocationRow = (liId: string, rowId: string) => {
|
||
setItemAllocations((prev) => {
|
||
const currentList = prev[liId] || [];
|
||
if (currentList.length <= 1) return prev;
|
||
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
|
||
});
|
||
};
|
||
|
||
const updateAllocationRow = (
|
||
liId: string,
|
||
rowId: string,
|
||
patch: Partial<{ project_name: string; quantity: number | string }>,
|
||
) => {
|
||
setItemAllocations((prev) => {
|
||
const currentList = prev[liId] || [];
|
||
return {
|
||
...prev,
|
||
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
|
||
};
|
||
});
|
||
};
|
||
|
||
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";
|
||
});
|
||
|
||
for (const li of active) {
|
||
const allocs = itemAllocations[li.id] || [];
|
||
const m = materialById(materials, li.material_id);
|
||
const title = formatMaterialTitle(m);
|
||
const targetQty = Number(li.quantity ?? 0);
|
||
|
||
if (!allocs.length) {
|
||
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
|
||
return;
|
||
}
|
||
|
||
for (const a of allocs) {
|
||
if (!a.project_name.trim()) {
|
||
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیفهای تخصیص را مشخص کنید.`);
|
||
return;
|
||
}
|
||
const qVal = parseFloat(String(a.quantity));
|
||
if (isNaN(qVal) || qVal <= 0) {
|
||
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگتر از صفر باشد.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const pNames = allocs.map((a) => a.project_name.trim());
|
||
if (new Set(pNames).size !== pNames.length) {
|
||
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||
return;
|
||
}
|
||
|
||
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||
const diff = Math.abs(sumQty - targetQty);
|
||
if (diff > 0.0001) {
|
||
toast.error(
|
||
`مجموع مقادیر تخصیصیافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
next.line_items.forEach((li) => {
|
||
if (li.rejected) return;
|
||
const allocs = itemAllocations[li.id];
|
||
if (allocs && allocs.length) {
|
||
li.allocations = allocs.map((a) => ({
|
||
project_name: a.project_name.trim(),
|
||
quantity: parseFloat(String(a.quantity)) || 0,
|
||
}));
|
||
li.target_project = { id: "", name: allocs[0].project_name.trim() };
|
||
} else {
|
||
const name = itemProjectsSel[li.id];
|
||
if (name !== undefined) {
|
||
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">
|
||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||
پذیرش پیشنهاد — {sup.name}
|
||
</DialogTitle>
|
||
</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">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||
</div>
|
||
<div className="mb-4 text-[12px] text-muted">
|
||
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده میشود. در صورت خرید یک کالا برای چند پروژه، میتوانید آن را به چند سطر تفکیک کنید.
|
||
</div>
|
||
|
||
{projectOptions.length === 0 ? (
|
||
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
|
||
⚠️ هیچ پروژهای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژهها، حداقل یک پروژه ایجاد کنید.
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-4">
|
||
{allItems
|
||
.filter((li) => !removedItems.has(li.id))
|
||
.map((li) => {
|
||
const m = materialById(materials, li.material_id);
|
||
const targetQty = Number(li.quantity ?? 0);
|
||
const allocs = itemAllocations[li.id] || [];
|
||
const allocSum = allocs.reduce(
|
||
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
|
||
0
|
||
);
|
||
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
|
||
const isComplete = Math.abs(diff) < 0.0001;
|
||
const isOver = diff < -0.0001;
|
||
|
||
return (
|
||
<div
|
||
key={li.id}
|
||
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
|
||
>
|
||
{/* هدر کارت قلم */}
|
||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
|
||
<div className="flex items-center gap-2">
|
||
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||
<span className="text-[13.5px] font-bold text-slate-800">
|
||
{formatMaterialTitle(m)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
|
||
کل: {fmtNum(targetQty)} {li.unit}
|
||
</span>
|
||
|
||
{isComplete ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
|
||
</span>
|
||
) : isOver ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
|
||
<AlertCircle className="w-3 h-3" />
|
||
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||
<AlertCircle className="w-3 h-3" />
|
||
باقیمانده: {fmtNum(diff)} {li.unit}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||
<div className="flex flex-col gap-2.5 mb-3">
|
||
{allocs.map((alloc) => {
|
||
const otherUsedProjects = new Set(
|
||
allocs
|
||
.filter((a) => a.rowId !== alloc.rowId)
|
||
.map((a) => a.project_name.trim())
|
||
.filter(Boolean)
|
||
);
|
||
|
||
const words = numberToPersianWords(alloc.quantity);
|
||
|
||
return (
|
||
<div
|
||
key={alloc.rowId}
|
||
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||
>
|
||
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||
<div className="flex-1 min-w-0">
|
||
<select
|
||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
|
||
value={alloc.project_name}
|
||
onChange={(e) =>
|
||
updateAllocationRow(li.id, alloc.rowId, {
|
||
project_name: e.target.value,
|
||
})
|
||
}
|
||
>
|
||
<option value="">انتخاب پروژه...</option>
|
||
{projectOptions.map((p) => {
|
||
const isUsed = otherUsedProjects.has(p);
|
||
return (
|
||
<option key={p} value={p} disabled={isUsed}>
|
||
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</div>
|
||
|
||
{/* دکمه حذف موبایل */}
|
||
<button
|
||
type="button"
|
||
disabled={allocs.length <= 1}
|
||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||
title="حذف این ردیف تخصیص"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
|
||
<div className="flex flex-col w-full sm:w-44 shrink-0">
|
||
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
min="0"
|
||
placeholder="مقدار"
|
||
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||
style={{ MozAppearance: "textfield" }}
|
||
dir="ltr"
|
||
value={alloc.quantity}
|
||
onChange={(e) =>
|
||
updateAllocationRow(li.id, alloc.rowId, {
|
||
quantity: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<div
|
||
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
|
||
title={`واحد: ${li.unit}`}
|
||
>
|
||
{li.unit}
|
||
</div>
|
||
</div>
|
||
|
||
{words ? (
|
||
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
|
||
{words} {li.unit}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپداون و اینپوت */}
|
||
<button
|
||
type="button"
|
||
disabled={allocs.length <= 1}
|
||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||
title="حذف این ردیف تخصیص"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* فوتر کارت قلم */}
|
||
<div className="flex items-center justify-between pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => addAllocationRow(li.id, targetQty)}
|
||
disabled={
|
||
allocs.length >= projectOptions.length && projectOptions.length > 0
|
||
}
|
||
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
|
||
</button>
|
||
<span className="text-[11px] text-muted">
|
||
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
|
||
</span>
|
||
</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>
|
||
);
|
||
} |