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.
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { Package } from "lucide-react";
|
||
import type { Quote } from "../../lib/types";
|
||
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
||
import { activeItems, formatMaterialTitle, materialById, supplierById } from "../../lib/quoteUtils";
|
||
import { fmtNum } from "../../lib/formatters";
|
||
import { groupCommas } from "../../lib/numutil";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../../components/ui/dialog";
|
||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||
|
||
export interface ArriveModalProps {
|
||
quoteId: string | null;
|
||
onClose: () => void;
|
||
}
|
||
|
||
// مودال رسید: تیک بزنید کدام قلمها رسیدهاند؛ تا همه نرسیده باشند quote پذیرفته میماند
|
||
export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
||
const quotes = useQuotes();
|
||
const suppliers = useSuppliers();
|
||
const materials = useMaterials();
|
||
const updateQuote = useUpdateQuote();
|
||
|
||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||
|
||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||
// تاریخ و زمان رسید — پیشفرض الان
|
||
const [arriveDate, setArriveDate] = useState<string | null>(() => new Date().toISOString());
|
||
const [ready, setReady] = useState(false);
|
||
|
||
useMemo(() => {
|
||
if (!q) return;
|
||
const set = new Set<string>();
|
||
activeItems(q).forEach((li) => {
|
||
if (li.arrived_date) set.add(li.id);
|
||
});
|
||
setChecked(set);
|
||
setReady(true);
|
||
}, [q]);
|
||
|
||
if (!q || !ready) return null;
|
||
|
||
const sup = supplierById(suppliers, q.supplier_id);
|
||
const items = activeItems(q);
|
||
|
||
const confirm = () => {
|
||
if (!checked.size) {
|
||
toast.error("حداقل یک قلم را برای رسید انتخاب کنید.");
|
||
return;
|
||
}
|
||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||
const arrivedIso = arriveDate || new Date().toISOString();
|
||
const now = new Date().toISOString();
|
||
next.line_items.forEach((li) => {
|
||
li.arrived_date = checked.has(li.id) ? arrivedIso : null;
|
||
});
|
||
if (activeItems(next).every((li) => li.arrived_date)) {
|
||
next.status = "arrived";
|
||
next.arrived_date = arrivedIso;
|
||
next.events.push({ type: "arrived", occurred_at: now });
|
||
} else {
|
||
next.arrived_date = null;
|
||
}
|
||
void updateQuote.mutateAsync({ id: q.id, patch: next });
|
||
setReady(false);
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>ثبت رسید — {sup.name}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="text-[12px] text-muted">
|
||
کدام قلمها رسیدهاند؟ تیک بزنید و ثبت کنید. تا وقتی همهی قلمها نرسیدهاند، پیشنهاد
|
||
«پذیرفته شده» میماند.
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{items.map((li) => {
|
||
const m = materialById(materials, li.material_id);
|
||
return (
|
||
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
||
<input
|
||
type="checkbox"
|
||
className="accent-green"
|
||
checked={checked.has(li.id)}
|
||
onChange={(e) => {
|
||
const next = new Set(checked);
|
||
if (e.target.checked) next.add(li.id);
|
||
else next.delete(li.id);
|
||
setChecked(next);
|
||
}}
|
||
/>
|
||
<span>
|
||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||
{formatMaterialTitle(m)}
|
||
</span>
|
||
<span className="arrive-meta">
|
||
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* تاریخ و زمان رسید */}
|
||
<div className="field max-w-xs">
|
||
<label>تاریخ و زمان رسید</label>
|
||
<JalaliDatePicker value={arriveDate} onChange={setArriveDate} placeholder="تاریخ و ساعت" withTime />
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="slate" onClick={onClose}>
|
||
انصراف
|
||
</Button>
|
||
<Button variant="green" onClick={confirm}>
|
||
ثبت رسید
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|