Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.
Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
(they leave the global auth dep, so the other six file routes now carry
it explicitly — nothing is open by omission)
Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
the modal header
Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
hangs when the package mirror is unreachable (502s)
964 lines
42 KiB
TypeScript
964 lines
42 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, 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;
|
|
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 [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.category || "",
|
|
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([]));
|
|
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: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
|
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;
|
|
|
|
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.category) === 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;
|
|
}
|
|
|
|
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) => 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="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-1 sm:grid-cols-2 gap-2.5">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</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 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">
|
|
<label className="text-[11px] font-semibold text-gray-600">برند</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 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="grid grid-cols-2 gap-2.5">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
|
<MultiSelectCombobox
|
|
disabled={hasReceipts}
|
|
values={row.specs}
|
|
onChange={(specs) => patchItem(row.key, { specs })}
|
|
options={specOptions}
|
|
placeholder="مشخصه"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[11px] font-semibold text-gray-600">واحد</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 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="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</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="flex flex-col gap-1">
|
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</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="flex flex-col gap-1">
|
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</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>
|
|
|
|
{/* شرایط پرداخت برای این قلم */}
|
|
<div className="pt-2 border-t border-gray-100">
|
|
{row.rejected ? (
|
|
<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 className="flex flex-col gap-1.5">
|
|
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{tsCards.map((card, cIdx) => {
|
|
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
|
return (
|
|
<label
|
|
key={card.key}
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
|
isSelected
|
|
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
|
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
|
hasReceipts && "cursor-not-allowed opacity-60",
|
|
)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
disabled={hasReceipts}
|
|
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
|
checked={isSelected}
|
|
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] });
|
|
}}
|
|
/>
|
|
<span>{tsLabel(card, cIdx)}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{/* تب ۲: شرایط پرداخت */}
|
|
{activeTab === "terms" && (
|
|
<div className="space-y-3.5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</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) => (
|
|
<div
|
|
key={card.key}
|
|
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
|
>
|
|
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="flex h-5 w-5 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">{tsLabel(card, idx)}</span>
|
|
<span className="text-[11px] text-gray-500 font-medium">
|
|
({faNum(card.milestones.length)} مرحله)
|
|
</span>
|
|
</div>
|
|
{!hasReceipts && tsCards.length > 1 && (
|
|
<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={() => removeTsCard(card.key)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
<span className="text-[11px]">حذف</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 mb-3">
|
|
{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
|
|
type="button"
|
|
variant="line"
|
|
size="sm"
|
|
className="w-full sm:w-auto text-xs text-gray-700"
|
|
onClick={() =>
|
|
updateTs(card.key, {
|
|
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
|
})
|
|
}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 me-1" />
|
|
افزودن مرحله پرداخت
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</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>
|
|
)}
|
|
</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>
|
|
</>
|
|
);
|
|
} |