fix(procurement): improve mobile layout for QuoteCard and EditModal

This commit is contained in:
Behrad
2026-09-17 13:19:14 +03:30
parent 7b3ae3cb60
commit 435e6cbb21
2 changed files with 433 additions and 379 deletions
+376 -346
View File
@@ -61,7 +61,7 @@ export interface EditModalProps {
onClose: () => void; onClose: () => void;
} }
// مودال ویرایش کامل پیشنهاد // مودال ویرایش کامل پیشنهاد — بهینه‌شده برای صفحات موبایل
export function EditModal({ quoteId, onClose }: EditModalProps) { export function EditModal({ quoteId, onClose }: EditModalProps) {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -112,7 +112,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} }
}; };
// گزینه‌های مشخصه — همه‌ی مشخصه‌های ثبت‌شده (کش) + رجیستری سراسری // گزینه‌های مشخصه
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]); const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -120,7 +120,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.specsList() .specsList()
.then((list) => alive && setGlobalSpecs(list)) .then((list) => alive && setGlobalSpecs(list))
.catch(() => {}); .catch(() => {});
return () => { alive = false; }; return () => {
alive = false;
};
}, []); }, []);
const specOptions = useMemo(() => { const specOptions = useMemo(() => {
@@ -131,7 +133,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials, itemRows, globalSpecs]); }, [materials, itemRows, globalSpecs]);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های خود پیشنهاد // گزینه‌های پروژه
const projectOptions = useMemo(() => { const projectOptions = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name)); projects.forEach((p) => p.name && set.add(p.name));
@@ -183,7 +185,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
setReady(true); setReady(true);
}, [q, suppliers, materials]); }, [q, suppliers, materials]);
// فاکتورها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی // بارگذاری فاکتورها
useEffect(() => { useEffect(() => {
if (!quoteId) return; if (!quoteId) return;
let alive = true; let alive = true;
@@ -250,7 +252,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const next: Quote = JSON.parse(JSON.stringify(q)); const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده — موجود یا جدید // تأمین‌کننده
if (supplierName.trim()) { if (supplierName.trim()) {
const norm = normalize(supplierName); const norm = normalize(supplierName);
const exist = suppliers.find((s) => normalize(s.name) === norm); const exist = suppliers.find((s) => normalize(s.name) === norm);
@@ -259,7 +261,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} else { } else {
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] }; const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
const created = await addSupplier.mutateAsync(newSup); const created = await addSupplier.mutateAsync(newSup);
next.supplier_id = created.id; // UUID واقعی از بک‌اند next.supplier_id = created.id;
} }
} }
@@ -268,7 +270,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
// termsheetها — id موجود حفظ می‌شود، جدیدها ساخته می‌شوند // termsheetها
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
tsCards.forEach((card, i) => { tsCards.forEach((card, i) => {
@@ -296,7 +298,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const qty = row.qty; const qty = row.qty;
const price = row.price; const price = row.price;
const total = row.total; const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی if (!cat && !brand && qty == null && price == null) continue;
let material = materialsCopy.find( let material = materialsCopy.find(
(m) => (m) =>
@@ -318,7 +320,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
base_unit: unit, base_unit: unit,
}; };
materialsCopy.push(nm); materialsCopy.push(nm);
// منتظر ساخت متریال می‌مانیم تا id واقعی (UUID) به قلم وصل شود
const created = await addMaterial.mutateAsync(nm); const created = await addMaterial.mutateAsync(nm);
materialId = created.id; materialId = created.id;
} }
@@ -366,7 +367,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} }
next.line_items = newItems.length ? newItems : next.line_items; next.line_items = newItems.length ? newItems : next.line_items;
// ترم‌شیت بدون قلم بی‌معنی است — حذف
const referencedTs = new Set<string>(); const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
@@ -383,7 +383,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.arrived_date = arrivedDate; next.arrived_date = arrivedDate;
next.status = status as Quote["status"]; next.status = status as Quote["status"];
// پاک‌سازی خودکار در بازگشت وضعیت
if (next.status === "offered") { if (next.status === "offered") {
next.accepted_date = null; next.accepted_date = null;
next.arrived_date = null; next.arrived_date = null;
@@ -417,363 +416,394 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return ( return (
<> <>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
<DialogHeader> <DialogHeader>
<DialogTitle>ویرایش پیشنهاد</DialogTitle> <DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
</DialogHeader> </DialogHeader>
{hasReceipts && ( {hasReceipts && (
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12.5px] font-medium text-amber"> <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" /> <AlertCircle className="h-4 w-4 shrink-0" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span> <span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1"> {/* اطلاعات بالایی — تک‌ستونه در موبایل، ۲ ستونه در دسکتاپ */}
<div className="field"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label>تأمین‌کننده</label> <div className="flex min-w-0 flex-col gap-1.5">
<Combobox <label className="text-xs font-semibold text-muted">تأمین‌کننده</label>
value={supplierName} <div className="w-full min-w-0">
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)} <Combobox
items={suppliers.map((s) => ({ id: s.id, name: s.name }))} value={supplierName}
allowAdd onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
addSuffix="به عنوان تأمین‌کنندهٔ جدید" items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
placeholder="نام تأمین‌کننده" allowAdd
onInputChange={(v) => setSupplierName(v)} addSuffix="به عنوان تأمین‌کنندهٔ جدید"
/> placeholder="نام تأمین‌کننده"
</div> onInputChange={(v) => setSupplierName(v)}
<div className="field"> />
<label>پروژه</label> </div>
<MultiSelectCombobox </div>
values={selectedProjects}
onChange={setSelectedProjects}
options={projectOptions}
placeholder="انتخاب پروژه..."
/>
</div>
<div className="field col-span-2">
<label>یادداشت</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="flex min-w-0 flex-col gap-1.5">
<div className="border-t border-line pt-3"> <label className="text-xs font-semibold text-muted">پروژه</label>
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div> <div className="w-full min-w-0">
<div className="flex flex-col gap-3"> <MultiSelectCombobox
{itemRows.map((row) => ( values={selectedProjects}
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}> onChange={setSelectedProjects}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> options={projectOptions}
<div className="field"> placeholder="انتخاب پروژه..."
<label>متریال</label> />
<input </div>
disabled={hasReceipts} </div>
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="متریال" <div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
value={row.cat} <label className="text-xs font-semibold text-muted">یادداشت</label>
onChange={(e) => patchItem(row.key, { cat: e.target.value })} <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> </div>
<div className="field">
<label>برند</label> {/* تخصیص termsheet */}
<input <div className="mt-2.5 border-t border-line/60 pt-2">
disabled={hasReceipts} {row.rejected ? (
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" <label className="ts-cb-label e-restore">
placeholder="برند" <input
value={row.brand} type="checkbox"
onChange={(e) => patchItem(row.key, { brand: e.target.value })} checked={row.restore}
/> onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
</div> />
<div className="field"> بازگرداندن به معامله
<label>مشخصه</label> </label>
<MultiSelectCombobox ) : (
disabled={hasReceipts} <>
values={row.specs} <div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
onChange={(specs) => patchItem(row.key, { specs })} <div className="flex flex-wrap gap-1.5">
options={specOptions} {tsCards.map((card, idx) => (
placeholder="مشخصه" <label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
/> <input
</div> type="checkbox"
<div className="field"> disabled={hasReceipts}
<label>واحد</label> className="me-1 accent-accent disabled:cursor-not-allowed"
<input checked={row.tsDomIds.includes(card.domId || card.key)}
disabled={hasReceipts} onChange={(e) => {
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" const next = new Set(row.tsDomIds);
placeholder="واحد" const domId = card.domId || card.key;
value={row.unit} if (e.target.checked) next.add(domId);
onChange={(e) => patchItem(row.key, { unit: e.target.value })} else next.delete(domId);
/> patchItem(row.key, { tsDomIds: [...next] });
</div> }}
<div className="field"> />
<label>تعداد</label> {tsLabel(card, idx)}
<NumberInput </label>
disabled={hasReceipts} ))}
value={row.qty} </div>
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="field">
<label>قیمت واحد</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="field">
<label>قیمت کل</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="flex items-end justify-end">
{!hasReceipts && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
حذف
</button>
)} )}
</div> </div>
</div> </div>
{/* تخصیص termsheet / بازگرداندن */} ))}
<div className="mt-2.5 border-t border-line pt-2"> </div>
{row.rejected ? ( {!hasReceipts && (
<label className="ts-cb-label e-restore"> <Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
<input + افزودن قلم
type="checkbox" </Button>
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", 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> </div>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
+ افزودن قلم
</Button>
)}
</div>
{/* termsheetها */} {/* termsheetها */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div> <div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2.5">
{tsCards.map((card, idx) => ( {tsCards.map((card, idx) => (
<div key={card.key} className="variant-card"> <div key={card.key} className="variant-card">
<div className="variant-head"> <div className="variant-head">
<span className="variant-num">{tsLabel(card, idx)}</span> <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 && ( {!hasReceipts && (
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}> <Button
× variant="line"
</button> size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</Button>
)} )}
</div> </div>
<div className="mb-1 flex flex-col gap-1.5"> ))}
{card.milestones.map((ms, i) => ( </div>
<MilestoneRow {!hasReceipts && (
key={i} <Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
milestone={ms} + افزودن termsheet
disabled={hasReceipts} </Button>
onChange={(nextMs) => )}
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) }) </div>
}
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })} {/* فاکتورها */}
/> <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-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>
))} ))}
</div> </select>
{!hasReceipts && ( {hasReceipts && (
<Button <div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
variant="line" این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
size="sm" </div>
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</Button>
)} )}
</div> </div>
))} </div>
</div> </div>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
+ افزودن termsheet
</Button>
)}
</div>
{/* فاکتورها */} {/* فوتر و دکمه‌های اقدام */}
<div className="border-t border-line pt-3"> <DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> {q.status === "offered" ? (
<FileList <Button
files={invoices} type="button"
onChange={setInvoices} variant="danger"
upload={(f) => api.files.upload(q.id, "invoice", f)} onClick={() => setShowDeleteConfirm(true)}
onDelete={(a) => className="w-full gap-1.5 sm:w-auto"
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-2 text-[12px] text-muted">تاریخ‌ها و وضعیت:</div>
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="field">
<label>تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
<div className="field col-span-2">
<label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label>
<select
className="h-9 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) => ( <Trash2 className="h-4 w-4" />
<option key={st} value={st}> <span>حذف پیشنهاد</span>
{STATUS_LABEL[st]} </Button>
</option> ) : (
))} <div />
</select> )}
{hasReceipts && ( <div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber"> <Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است. انصراف
</div> </Button>
)} <Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
ذخیره تغییرات
</Button>
</div> </div>
</div> </DialogFooter>
</div> </DialogContent>
</Dialog>
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse"> {/* مودال تأیید حذف */}
{q.status === "offered" ? ( <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 <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => setShowDeleteConfirm(true)} onClick={() => void handleDelete()}
className="gap-1.5" disabled={deleting}
> >
<Trash2 className="h-4 w-4" /> {deleting ? "در حال حذف..." : "حذف نهایی"}
<span>حذف پیشنهاد</span>
</Button> </Button>
) : ( </DialogFooter>
<div /> </DialogContent>
)} </Dialog>
<div className="flex items-center gap-2"> </>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* مدال تأیید حذف پیشنهاد */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md">
<DialogHeader>
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm text-muted">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</div>
<DialogFooter className="flex items-center justify-end gap-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>
</>
); );
} }
+57 -33
View File
@@ -176,29 +176,60 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</div> </div>
{/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */} {/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */}
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 p-3 sm:gap-x-5 sm:gap-y-3 sm:p-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
<InfoCell label="اقلام"> {/* ۱. پروژه — تمام‌عرض در موبایل */}
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
</span>
</div>
</InfoCell>
{/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
{summary.count > 1 ? ( {summary.count > 1 ? (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-1.5">
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span> <span className="font-bold text-xs text-ink sm:text-sm">
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5"> {fmtNum(summary.count)} قلم کالا
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => { </span>
const m = materialById(store.materials, li.material_id); <div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
return ( {store.quotes
<span key={li.id} className="truncate"> .find((x) => x.id === q.id)
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit}) ?.line_items?.filter((li) => !li.rejected)
</span> .slice(0, 3)
); .map((li) => {
})} const m = materialById(store.materials, li.material_id);
return (
<div
key={li.id}
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
>
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
• {materialLabel(m)}
</span>
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
{fmtNum(li.quantity)}{" "}
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
</span>
</div>
);
})}
{(q.line_items?.length || 0) > 3 && ( {(q.line_items?.length || 0) > 3 && (
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span> <span className="pt-0.5 text-[10px] text-muted">
و {(q.line_items?.length || 0) - 3} قلم دیگر...
</span>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<span className="truncate">{summary.text}</span> <div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
<span className="break-words">{summary.text}</span>
</div>
)} )}
{unitWarns.map((w) => ( {unitWarns.map((w) => (
<Link <Link
key={w.unit} key={w.unit}
@@ -206,25 +237,17 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]" className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`} title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
> >
<TriangleAlert className="h-2.5 w-2.5" /> <TriangleAlert className="h-2.5 w-2.5 shrink-0" />
واحد جدید: {w.unit} واحد جدید: {w.unit}
</Link> </Link>
))} ))}
</InfoCell> </InfoCell>
<InfoCell label="پروژه"> {/* ۳. مجموع مبلغ */}
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]"> <InfoCell label="مجموع مبلغ" className="col-span-1">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
</span>
</span>
</InfoCell>
<InfoCell label="مجموع مبلغ">
{pc ? ( {pc ? (
<div> <div>
<div className="text-sm font-extrabold text-ink tabular-nums sm:text-base"> <div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span> {pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
</div> </div>
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div> <div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
@@ -234,10 +257,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
)} )}
</InfoCell> </InfoCell>
<InfoCell label="شرایط معامله"> {/* ۴. شرایط معامله */}
<InfoCell label="شرایط معامله" className="col-span-1">
{ts.title !== "—" ? ( {ts.title !== "—" ? (
<div> <div>
<div className="font-semibold text-xs sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}> <div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
{ts.title} {ts.title}
</div> </div>
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>} {ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
@@ -248,12 +272,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell> </InfoCell>
</div> </div>
{/* تاریخ‌ها و اکشن‌ها (۲ ستونه در موبایل، نواری در دسکتاپ) */} {/* تاریخ‌ها و اکشن‌ها */}
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]"> <div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
<span className="inline-flex items-center gap-1.5 truncate"> <span className="inline-flex items-center gap-1.5 truncate">
<Package className="h-3 w-3 shrink-0" /> <Package className="h-3 w-3 shrink-0" />
<span>تاریخ:</span> <span>تاریخ:</span>
<b className="text-ink tabular-nums">{fmtDate(q.quote_date)}</b> <b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
</span> </span>
<span className="inline-flex items-center gap-1.5 truncate"> <span className="inline-flex items-center gap-1.5 truncate">
@@ -262,7 +286,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}> <b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)} {fmtDate(q.expiry_date)}
</b> </b>
{expired && <AlarmClock className="h-3 w-3 text-amber shrink-0" aria-label="منقضی" />} {expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">