1) Quote card header: an official/unofficial badge (رسمی / غیررسمی) renders next to the supplier name from quotes.is_official (the «فاکتور رسمی» checkbox) — green for official, muted slate for unofficial. 2) Conversion Factors sidebar: the material group and variant labels no longer truncate with ellipsis — whitespace-normal + break-words so long Persian names wrap cleanly to the next line. 3) Global fixed conversions are now seeded in the unified seeder (app/db/init_db.py — the same file that seeds categories, base units and the superuser path), NOT hardcoded in the frontend: تن→کیلوگرم (1000), کیلوگرم→گرم (1000), هزار عدد→عدد (1000). The supporting units (گرم, هزار عدد) were added to the idempotent BASE_UNITS seed; inserts use ON CONFLICT (from_unit, to_unit) DO NOTHING against the uq_standard_conversion key so every startup is safe. Verified live: 3 rows after first boot AND still 3 rows after an api restart. 4) Universal proforma attachments: the Edit modal now has a پیشفاکتورها section (list/upload/delete via the existing MinIO-backed files API) alongside the invoices section, active at every quote lifecycle stage — including accepted/arrived and receipts-locked quotes (the section is deliberately not gated by hasReceipts; the backend already permits uploads in any status). View/delete of existing proformas unchanged elsewhere. 5) EditModal fixes: (a) the supplier Combobox no longer opens on mount — Radix Dialog autofocus was triggering its onFocus; Combobox gains an openOnFocus prop (default true) and the modal passes false, so the dropdown now opens only on explicit click or typing; (b) the line-item material field binds to the material's own name (m.name) instead of its category relation, and the save path matches materials by name+brand+specs accordingly. Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
851 lines
35 KiB
TypeScript
851 lines
35 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Trash2, AlertCircle } 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, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||
import { faNum } from "../../lib/formatters";
|
||
import { normalize } from "../../lib/normalize";
|
||
import { specsKey } from "../../lib/quoteUtils";
|
||
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 { MilestoneRow } from "../../components/MilestoneRow";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../../components/ui/dialog";
|
||
|
||
interface EditItemRow {
|
||
key: string;
|
||
origLi?: LineItem;
|
||
projects?: { id: string; name: string }[];
|
||
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; // "" یا "new-ts-..." برای کارت جدید
|
||
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 [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);
|
||
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
||
const candidateSet = new Set<string>();
|
||
(q.projects || []).forEach((p) => p && candidateSet.add(p));
|
||
if (q.project) candidateSet.add(q.project);
|
||
q.line_items.forEach((li) => {
|
||
(li.projects || []).forEach((p) => {
|
||
const n = typeof p === "string" ? p : p.name;
|
||
if (n) candidateSet.add(n);
|
||
});
|
||
});
|
||
setSelectedProjects([...candidateSet]);
|
||
setNotes(q.notes || "");
|
||
setItemRows(
|
||
q.line_items.map((li) => {
|
||
const m = materialById(materials, li.material_id);
|
||
return {
|
||
key: li.id,
|
||
origLi: li,
|
||
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||
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]);
|
||
|
||
if (!q || !ready) return null;
|
||
|
||
const tsLabel = (card: EditTsCard, idx: number) => {
|
||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
||
};
|
||
|
||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||
};
|
||
|
||
const addItemRow = () => {
|
||
setItemRows((rows) => [
|
||
...rows,
|
||
{
|
||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
||
cat: "",
|
||
brand: "",
|
||
specs: [],
|
||
qty: null,
|
||
unit: "",
|
||
price: null,
|
||
total: null,
|
||
tsDomIds: [],
|
||
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-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
||
]);
|
||
};
|
||
|
||
const removeTsCard = (key: string) => {
|
||
setTsCards((cards) => cards.filter((c) => c.key !== 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;
|
||
|
||
// termsheetها
|
||
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;
|
||
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 = materialsCopy.find(
|
||
(m) =>
|
||
normalize(m.name) === normalize(cat) &&
|
||
normalize(m.brand) === normalize(brand) &&
|
||
specsKey(m.specs) === specsKey(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 || "",
|
||
category: cat,
|
||
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.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;
|
||
}
|
||
|
||
const candProjs =
|
||
row.projects && row.projects.length
|
||
? row.projects
|
||
: row.origLi?.projects && row.origLi.projects.length
|
||
? row.origLi.projects
|
||
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
||
|
||
newItems.push({
|
||
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "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: candProjs,
|
||
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) => referencedTs.has(t.id));
|
||
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="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{hasReceipts && (
|
||
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber">
|
||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* اطلاعات بالایی — تکستونه در موبایل، ۲ ستونه در دسکتاپ */}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">تأمینکننده</label>
|
||
<div className="w-full min-w-0">
|
||
<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)}
|
||
openOnFocus={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">پروژه</label>
|
||
<div className="w-full min-w-0">
|
||
<MultiSelectCombobox
|
||
values={selectedProjects}
|
||
onChange={setSelectedProjects}
|
||
options={projectOptions}
|
||
placeholder="انتخاب پروژه..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||
<textarea
|
||
rows={2}
|
||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* اقلام */}
|
||
<div className="border-t border-line pt-3">
|
||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||
<div className="flex flex-col gap-3">
|
||
{itemRows.map((row) => (
|
||
<div
|
||
key={row.key}
|
||
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4">
|
||
{/* متریال — تمامعرض در موبایل */}
|
||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
|
||
<label className="text-[11px] font-semibold text-muted">متریال</label>
|
||
<input
|
||
disabled={hasReceipts}
|
||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||
placeholder="متریال"
|
||
value={row.cat}
|
||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* برند */}
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||
<label className="text-[11px] font-semibold text-muted">برند</label>
|
||
<input
|
||
disabled={hasReceipts}
|
||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||
placeholder="برند"
|
||
value={row.brand}
|
||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* واحد */}
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||
<label className="text-[11px] font-semibold text-muted">واحد</label>
|
||
<input
|
||
disabled={hasReceipts}
|
||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||
placeholder="واحد"
|
||
value={row.unit}
|
||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* مشخصه — تمامعرض در موبایل */}
|
||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1">
|
||
<label className="text-[11px] font-semibold text-muted">مشخصه</label>
|
||
<div className="w-full min-w-0">
|
||
<MultiSelectCombobox
|
||
disabled={hasReceipts}
|
||
values={row.specs}
|
||
onChange={(specs) => patchItem(row.key, { specs })}
|
||
options={specOptions}
|
||
placeholder="مشخصه"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* تعداد */}
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||
<label className="text-[11px] font-semibold text-muted">تعداد</label>
|
||
<NumberInput
|
||
disabled={hasReceipts}
|
||
value={row.qty}
|
||
onChange={(qty) => {
|
||
patchItem(row.key, {
|
||
qty,
|
||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||
});
|
||
}}
|
||
placeholder="تعداد"
|
||
/>
|
||
</div>
|
||
|
||
{/* قیمت واحد */}
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
|
||
<NumberInput
|
||
disabled={hasReceipts}
|
||
value={row.price}
|
||
onChange={(price) => {
|
||
patchItem(row.key, {
|
||
price,
|
||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||
});
|
||
}}
|
||
placeholder="قیمت واحد"
|
||
maxDecimals={2}
|
||
/>
|
||
</div>
|
||
|
||
{/* قیمت کل */}
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||
<label className="text-[11px] font-semibold text-muted">قیمت کل</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="قیمت کل"
|
||
/>
|
||
</div>
|
||
|
||
{/* دکمه حذف */}
|
||
<div className="col-span-1 flex items-end justify-end pb-0.5">
|
||
{!hasReceipts && (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-xs text-red hover:bg-red-bg/50 transition-colors"
|
||
title="حذف قلم"
|
||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
<span>حذف قلم</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* تخصیص termsheet */}
|
||
<div className="mt-2.5 border-t border-line/60 pt-2">
|
||
{row.rejected ? (
|
||
<label className="ts-cb-label e-restore">
|
||
<input
|
||
type="checkbox"
|
||
checked={row.restore}
|
||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||
/>
|
||
بازگرداندن به معامله
|
||
</label>
|
||
) : (
|
||
<>
|
||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{tsCards.map((card, idx) => (
|
||
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||
<input
|
||
type="checkbox"
|
||
disabled={hasReceipts}
|
||
className="me-1 accent-accent disabled:cursor-not-allowed"
|
||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||
onChange={(e) => {
|
||
const next = new Set(row.tsDomIds);
|
||
const domId = card.domId || card.key;
|
||
if (e.target.checked) next.add(domId);
|
||
else next.delete(domId);
|
||
patchItem(row.key, { tsDomIds: [...next] });
|
||
}}
|
||
/>
|
||
{tsLabel(card, idx)}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!hasReceipts && (
|
||
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
|
||
+ افزودن قلم
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* termsheetها */}
|
||
<div className="border-t border-line pt-3">
|
||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||
<div className="flex flex-col gap-2.5">
|
||
{tsCards.map((card, idx) => (
|
||
<div key={card.key} className="variant-card">
|
||
<div className="variant-head">
|
||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||
{!hasReceipts && (
|
||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="mb-1 flex flex-col gap-1.5">
|
||
{card.milestones.map((ms, i) => (
|
||
<MilestoneRow
|
||
key={i}
|
||
milestone={ms}
|
||
disabled={hasReceipts}
|
||
onChange={(nextMs) =>
|
||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||
}
|
||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
||
/>
|
||
))}
|
||
</div>
|
||
{!hasReceipts && (
|
||
<Button
|
||
variant="line"
|
||
size="sm"
|
||
className="mt-1"
|
||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||
>
|
||
+ مرحله پرداخت
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!hasReceipts && (
|
||
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
|
||
+ افزودن termsheet
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* فاکتورها */}
|
||
<div className="border-t border-line pt-3">
|
||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||
<FileList
|
||
files={invoices}
|
||
onChange={setInvoices}
|
||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||
onDelete={(a) =>
|
||
a.id &&
|
||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||
)
|
||
}
|
||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||
/>
|
||
</div>
|
||
|
||
{/* پیشفاکتورها — در تمامی مراحل چرخه عمر پیشنهاد قابل افزودن/ویرایش است */}
|
||
<div className="border-t border-line pt-3">
|
||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||
<FileList
|
||
files={proformas}
|
||
onChange={setProformas}
|
||
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||
onDelete={(a) =>
|
||
a.id &&
|
||
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||
)
|
||
}
|
||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
||
/>
|
||
</div>
|
||
|
||
{/* تاریخها و وضعیت */}
|
||
<div className="border-t border-line pt-3">
|
||
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">تاریخ پیشنهاد</label>
|
||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
|
||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
|
||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
|
||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||
</div>
|
||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||
<select
|
||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed 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>
|
||
{hasReceipts && (
|
||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* فوتر و دکمههای اقدام */}
|
||
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||
{q.status === "offered" ? (
|
||
<Button
|
||
type="button"
|
||
variant="danger"
|
||
onClick={() => setShowDeleteConfirm(true)}
|
||
className="w-full gap-1.5 sm:w-auto"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
<span>حذف پیشنهاد</span>
|
||
</Button>
|
||
) : (
|
||
<div />
|
||
)}
|
||
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
|
||
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
|
||
انصراف
|
||
</Button>
|
||
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
|
||
ذخیره تغییرات
|
||
</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">
|
||
<DialogHeader>
|
||
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="py-2 text-sm text-muted">
|
||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||
</div>
|
||
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||
<Button
|
||
type="button"
|
||
variant="slate"
|
||
onClick={() => setShowDeleteConfirm(false)}
|
||
disabled={deleting}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="danger"
|
||
onClick={() => void handleDelete()}
|
||
disabled={deleting}
|
||
>
|
||
{deleting ? "در حال حذف..." : "حذف نهایی"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
} |