Files
ERP/frontend/src/pages/Dashboard/EditModal.tsx
T
MMD a84b33dde3 Merge prod-v8 (multi-project allocation, inter-project transfers) into the all-branches integration
Conflicts (all keep-both):
- init_db.py: v7 added ensure_bot_drafts_table, v8 added
  ensure_project_transfers_table at the same anchor — both functions and
  both startup calls kept
- main.py: same, both ensure calls kept
- ChangeInventoryPage.canSubmit: v9's WITHDRAWAL branch and v8's
  PROJECT_TRANSFER branch — both validation branches kept
2026-10-01 15:05:09 +03:30

978 lines
42 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 { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } 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 { cn } from "../../lib/utils";
import {
useMaterials,
useProjects,
useQuotes,
useSuppliers,
useUpdateQuote,
useDeleteQuote,
useAddSupplier,
useAddMaterial,
} from "../../hooks";
import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
import { faNum } from "../../lib/formatters";
import { normalize } from "../../lib/normalize";
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 { TermsheetCard } from "../../components/TermsheetCard";
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;
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 deleteQuote = useDeleteQuote();
const addSupplier = useAddSupplier();
const addMaterial = useAddMaterial();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const hasReceipts = useMemo(() => {
if (!q) return false;
if (q.status === "arrived") return true;
return (q.line_items || []).some(
(li) => (li.received_qty != null && li.received_qty > 0) || !!li.arrived_date,
);
}, [q]);
const [ready, setReady] = useState(false);
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
const [supplierName, setSupplierName] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!q) return;
setDeleting(true);
try {
await deleteQuote.mutateAsync(q.id);
toast.success("پیشنهاد خرید با موفقیت حذف شد");
setShowDeleteConfirm(false);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "حذف پیشنهاد ناموفق بود");
} finally {
setDeleting(false);
}
};
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => {
let alive = true;
void api
.specsList()
.then((list) => alive && setGlobalSpecs(list))
.catch(() => {});
return () => {
alive = false;
};
}, []);
const specOptions = useMemo(() => {
const set = new Set<string>();
globalSpecs.forEach((s) => s && set.add(s));
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, globalSpecs]);
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.name || "",
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]);
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]);
const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const getCoveredIds = (card: EditTsCard) => {
const cardId = card.domId || card.key;
const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
};
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [
...rows,
{
key: newKey,
cat: "",
brand: "",
specs: [],
qty: null,
unit: "",
price: null,
total: null,
tsDomIds: defaultTsId ? [defaultTsId] : [],
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-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
]);
};
const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
};
const confirm = async () => {
if (hasReceipts && status !== q.status) {
toast.error("این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.");
return;
}
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;
}
}
next.project = selectedProjects[0] || "";
next.projects = [...selectedProjects];
next.notes = notes.trim();
next.invoices = invoices;
next.proformas = proformas;
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;
tsIdMap[card.key] = 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 = matchMaterial(materialsCopy, cat, brand, specs);
let materialId: string;
if (material) {
materialId = material.id;
} else {
const nm = {
id: nextId("m", materialsCopy),
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
brand,
specs,
base_unit: unit,
};
materialsCopy.push(nm);
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.includes(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 ?? [],
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
});
}
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, idx) => referencedTs.has(t.id) || idx === 0);
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 ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : 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;
li.target_project = null;
li.target_project_id = null;
li.allocations = undefined;
});
// ادغام سطرهای تفکیک‌شده حاصل از تخصیص چندپروژه‌ای (الزام ۲)
const mergedMap = new Map<string, LineItem>();
for (const li of next.line_items) {
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
const existing = mergedMap.get(key);
if (existing) {
const q1 = Number(existing.quantity ?? 0);
const q2 = Number(li.quantity ?? 0);
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
if (existing.unit_price != null && existing.quantity != null) {
existing.total_price = existing.unit_price * existing.quantity;
}
} else {
mergedMap.set(key, { ...li });
}
}
next.line_items = Array.from(mergedMap.values());
} 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="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
{/* هدر */}
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div>
</DialogHeader>
{hasReceipts && (
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div>
)}
{/* تب‌بار */}
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
<button
type="button"
onClick={() => setActiveTab("items")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "items"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Package className="h-3.5 w-3.5" />
<span>اقلام و سفارش</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(itemRows.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("terms")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "terms"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<CreditCard className="h-3.5 w-3.5" />
<span>شرایط پرداخت</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(tsCards.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("dates")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "dates"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Calendar className="h-3.5 w-3.5" />
<span>وضعیت و اسناد</span>
{invoices.length > 0 && (
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(invoices.length)}
</span>
)}
</button>
</div>
{/* محتوا */}
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
{/* تب ۱: اقلام */}
{activeTab === "items" && (
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تأمین‌کننده</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="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">پروژه</label>
<MultiSelectCombobox
values={selectedProjects}
onChange={setSelectedProjects}
options={projectOptions}
placeholder="انتخاب پروژه..."
/>
</div>
</div>
<div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">یادداشت</label>
<textarea
rows={2}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30"
value={notes}
placeholder="یادداشت‌های داخلی این معامله..."
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
</div>
{itemRows.map((row, idx) => (
<div
key={row.key}
className={cn(
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
)}
>
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
<div className="flex items-center gap-1.5 min-w-0">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900 truncate">
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
</span>
</div>
{!hasReceipts && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="space-y-3">
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="نام متریال"
value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="برند"
value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
/>
</div>
</div>
{/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="انتخاب مشخصات..."
className="min-w-0"
/>
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
<NumberInput
disabled={hasReceipts}
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
qty,
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
});
}}
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد (شاخه، متر، کیلو...)"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput
disabled={hasReceipts}
value={row.price}
onChange={(price) => {
patchItem(row.key, {
price,
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
});
}}
placeholder="قیمت هر واحد"
showWords
maxDecimals={2}
className="!text-right text-right"
/>
</div>
{/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<NumberInput
disabled={hasReceipts}
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="قیمت کل قلم"
showWords
className="!text-right text-right"
/>
</div>
</div>
{row.rejected && (
<div className="pt-2 border-t border-gray-100">
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<input
type="checkbox"
className="accent-blue-600 h-4 w-4 rounded"
checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/>
بازگرداندن این قلم به معامله
</label>
</div>
)}
</div>
</div>
))}
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
onClick={addItemRow}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن قلم جدید</span>
</Button>
)}
</div>
</div>
)}
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
{activeTab === "terms" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
<div className="text-[11.5px] text-gray-500 mt-0.5">
روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
</div>
</div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="gap-1 text-xs text-gray-700"
onClick={addTsCard}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن گزینه جدید</span>
</Button>
)}
</div>
<div className="flex flex-col gap-3">
{tsCards.map((card, idx) => (
<TermsheetCard
key={card.key}
index={idx + 1}
milestones={card.milestones}
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
items={itemOptions}
coveredIds={getCoveredIds(card)}
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
onRemove={() => removeTsCard(card.key)}
/>
))}
</div>
</div>
)}
{/* تب ۳: وضعیت و اسناد */}
{activeTab === "dates" && (
<div className="space-y-3.5">
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
<select
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
value={status}
disabled={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
<div className="text-[11px] text-gray-500">
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
<div className="text-xs font-bold text-gray-900">تاریخ‌های چرخه خرید</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوست‌ها</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 : "حذف فاکتور ناموفق بود"),
)
}
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="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">پیش‌فاکتورها</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 : "حذف پیش‌فاکتور ناموفق بود"),
)
}
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>
)}
</div>
{/* فوتر */}
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
{q.status === "offered" ? (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">حذف</span>
</Button>
) : (
<div />
)}
<div className="flex items-center gap-2">
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
انصراف
</Button>
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
ذخیره تغییرات
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* مودال تایید حذف */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader>
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
</DialogHeader>
<div className="py-2 text-xs sm:text-sm text-gray-600">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</div>
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="slate"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleting}
>
انصراف
</Button>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "در حال حذف..." : "حذف نهایی"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}