The proforma FileList existed in prod_v5 (a181a87) but was dropped when the prod_v2 EditModal redesign (de177f3) was merged — that branch predates the feature and its version of the file was taken wholesale to keep the new tabbed layout, silently discarding the proforma block. Restores the state, the load effect, the saved patch field and the upload UI (on the تاریخ tab, next to the invoice card), using the current authenticated download/preview helpers rather than the removed urlOf.
959 lines
41 KiB
TypeScript
959 lines
41 KiB
TypeScript
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;
|
||
});
|
||
} 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>
|
||
</>
|
||
);
|
||
} |