Files
ERP/frontend/src/pages/Dashboard/ArriveModal.tsx
T
whqreza 5084623be5 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.
2026-09-16 19:14:06 +03:30

131 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}