feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
Quotes that already have warehouse receipts can no longer be edited or rolled back: - backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.» - reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer) - frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote) - date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings Per-item target project (candidate projects stay intact): - expand_service exposes the decision target project (decision.project + line_item.target_project) - the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched - AcceptModal/EditModal select the target project per line item Material titles: - quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals Verified: python compile + api/frontend image builds (tsc + vite) clean.
This commit is contained in:
@@ -5,11 +5,9 @@ import { api, nextDashedId } from "../../lib/api";
|
||||
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
||||
import {
|
||||
activeItems,
|
||||
formatMaterialTitle,
|
||||
itemTermsheetIds,
|
||||
materialById,
|
||||
materialLabel,
|
||||
materialSpecs,
|
||||
specsLabel,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
@@ -76,11 +74,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const projSel: Record<string, string> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
projSel[li.id] = names.length
|
||||
? names[0]
|
||||
: q.projects && q.projects.length
|
||||
? q.projects[0]
|
||||
: q.project || "";
|
||||
projSel[li.id] = li.target_project?.name
|
||||
? li.target_project.name
|
||||
: names.length
|
||||
? names[0]
|
||||
: q.projects && q.projects.length
|
||||
? q.projects[0]
|
||||
: q.project || "";
|
||||
});
|
||||
setItemProjectsSel(projSel);
|
||||
setReady(true);
|
||||
@@ -219,17 +219,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژهی هر قلم (تکی) + همگامسازی پروژههای سطح پیشنهاد
|
||||
// پروژهی هر قلم (تکی هدف خرید) + همگامسازی پروژههای سطح پیشنهاد
|
||||
const projUnion = new Set<string>();
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const name = itemProjectsSel[li.id];
|
||||
if (name === undefined) return;
|
||||
li.projects = name ? [{ id: "", name }] : [];
|
||||
li.target_project = name ? { id: "", name } : null;
|
||||
// توجه: li.projects دستنخورده باقی میماند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||
if (name) projUnion.add(name);
|
||||
});
|
||||
next.projects = [...projUnion];
|
||||
next.project = next.projects[0] || "";
|
||||
if (projUnion.size > 0) {
|
||||
next.projects = [...projUnion];
|
||||
next.project = next.projects[0] || "";
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
next.accepted_date = acceptDate || now;
|
||||
@@ -298,7 +301,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
{allItems.map((li) => {
|
||||
const removed = removedItems.has(li.id);
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
||||
<input
|
||||
@@ -314,8 +316,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[13px]">
|
||||
{materialLabel(m)}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||
@@ -351,14 +352,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||
<div className="mb-1.5 text-[12.5px]">
|
||||
<span className="font-semibold">
|
||||
{materialLabel(m)}
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
{specText ? <span className="text-muted"> · {specText}</span> : ""}
|
||||
<span className="ms-2 text-[11px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from "sonner";
|
||||
import { Package } from "lucide-react";
|
||||
import type { Quote } from "../../lib/types";
|
||||
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
||||
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { activeItems, formatMaterialTitle, materialById, supplierById } from "../../lib/quoteUtils";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -86,7 +86,6 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = materialSpecs(m).join(" · ");
|
||||
return (
|
||||
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
||||
<input
|
||||
@@ -102,8 +101,7 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
||||
/>
|
||||
<span>
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
<span className="arrive-meta">
|
||||
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2 } from "lucide-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";
|
||||
@@ -66,11 +68,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
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[]>([]);
|
||||
@@ -83,6 +94,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
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 specOptions = useMemo(() => {
|
||||
@@ -204,6 +232,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
if (hasReceipts && status !== q.status) {
|
||||
toast.error("این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.");
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// تأمینکننده — موجود یا جدید
|
||||
@@ -316,6 +349,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
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;
|
||||
@@ -332,7 +367,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
// تاریخها و وضعیت
|
||||
next.quote_date = quoteDate || q.quote_date;
|
||||
next.expiry_date = expiryDate || "";
|
||||
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"];
|
||||
@@ -348,6 +383,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
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;
|
||||
@@ -367,12 +404,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<>
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{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">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div className="field">
|
||||
<label>تأمینکننده</label>
|
||||
@@ -416,7 +461,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>متریال</label>
|
||||
<input
|
||||
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={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 })}
|
||||
@@ -425,7 +471,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>برند</label>
|
||||
<input
|
||||
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={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 })}
|
||||
@@ -434,6 +481,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>مشخصه</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
@@ -443,7 +491,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>واحد</label>
|
||||
<input
|
||||
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={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 })}
|
||||
@@ -452,6 +501,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>تعداد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
@@ -465,6 +515,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>قیمت واحد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
@@ -479,6 +530,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field">
|
||||
<label>قیمت کل</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.total}
|
||||
onChange={(t) => {
|
||||
patchItem(row.key, {
|
||||
@@ -491,15 +543,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<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>
|
||||
{!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>
|
||||
{/* تخصیص termsheet / بازگرداندن */}
|
||||
@@ -518,10 +572,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<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="chip cursor-pointer">
|
||||
<label key={card.key} className={cn("chip cursor-pointer", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="me-1 accent-accent"
|
||||
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);
|
||||
@@ -541,9 +596,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
||||
+ افزودن قلم
|
||||
</Button>
|
||||
{!hasReceipts && (
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
||||
+ افزودن قلم
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* termsheetها */}
|
||||
@@ -554,15 +611,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div key={card.key} className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||
×
|
||||
</button>
|
||||
{!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)) })
|
||||
}
|
||||
@@ -570,20 +630,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
{!hasReceipts && (
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
</Button>
|
||||
{!hasReceipts && (
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فاکتورها */}
|
||||
@@ -613,7 +677,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ پذیرش</label>
|
||||
@@ -628,7 +692,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<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={q.status === "arrived"}
|
||||
disabled={hasReceipts}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
@@ -637,22 +701,68 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{q.status === "arrived" && (
|
||||
{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>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
|
||||
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse">
|
||||
{q.status === "offered" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>حذف پیشنهاد</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } f
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
formatMaterialTitle,
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialLabel,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
specsLabel,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
@@ -69,7 +67,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const total = lineItemTotal(li);
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
|
||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||
@@ -97,8 +94,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
>
|
||||
<div className="fb-name">
|
||||
<Package className="me-1 inline h-3 w-3 text-muted" />
|
||||
{materialLabel(m)}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
{formatMaterialTitle(m)}
|
||||
</div>
|
||||
<div className="fb-meta">
|
||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
formatMaterialTitle,
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
@@ -92,11 +92,10 @@ export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
|
||||
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return (
|
||||
<span key={li.id} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
||||
{formatMaterialTitle(m)}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user