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:
2026-09-16 19:14:06 +03:30
parent 7ead60cd5c
commit 5084623be5
14 changed files with 444 additions and 158 deletions
+19 -23
View File
@@ -285,6 +285,8 @@ interface RawDecision {
id: string;
status: string;
chosen_item_termsheet_id?: string | null;
project_id?: string | null;
project?: RawProject | null;
adjusted_unit_price?: number | null;
decided_at?: string | null;
}
@@ -309,6 +311,7 @@ interface RawLineItem {
termsheet_ids?: string[] | null;
accepted_termsheet_id?: string | null; // id ترم‌شیت پذیرفته‌شده (task: خط آبی)
projects?: RawProject[] | null; // پروژه‌های تخصیص‌یافته به این قلم
target_project?: RawProject | null;
}
interface RawTermsheet {
id: string;
@@ -517,6 +520,7 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
}
function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
const targetProj = raw.target_project || raw.decision?.project || null;
return {
id: raw.id,
material_id: raw.material?.id || "",
@@ -537,6 +541,8 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
quantity: a.quantity,
})),
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
target_project: targetProj ? { id: targetProj.id, name: targetProj.name } : null,
target_project_id: raw.decision?.project_id || targetProj?.id || null,
};
}
@@ -662,10 +668,10 @@ function adaptAttachment(raw: RawAttachment): Attachment {
function toDateOnly(v: string | null | undefined): string | null {
if (!v) return null;
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
const str = String(v).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب می‌افتد)
const d = new Date(v);
const d = new Date(str);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
@@ -855,23 +861,17 @@ export const api = {
if (becomingAccepted) {
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
// تخصیص پروژه به هر قلم — نام‌ها به id واقعی ترجمه می‌شوند
const itemProjects: { line_item_id: string; project_ids: string[] }[] = [];
const allProjectIds: string[] = [];
const seenPids = new Set<string>();
// پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id)
const targetProjByLine = new Map<string, string>();
for (const li of patch.line_items || []) {
const ids: string[] = [];
for (const p of li.projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
if (!pid) continue;
ids.push(pid);
if (!seenPids.has(pid)) {
seenPids.add(pid);
allProjectIds.push(pid);
}
const tp = li.target_project;
if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid);
}
itemProjects.push({ line_item_id: li.id, project_ids: ids });
}
const allProjectIds = [...new Set([...targetProjByLine.values()])];
const payload = {
accepted_termsheets: acceptedTs.map((t) => ({
id: looksLikeUuid(t.id) ? t.id : undefined,
@@ -887,11 +887,7 @@ export const api = {
item_decisions: (patch.line_items || []).map((li) => {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0;
// پروژه‌ی واحدِ انتخاب‌شده برای این قلم — مستقیم داخل decision می‌رود تا
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
// نشان دهد؛ قبلاً فقط item_projects جداگانه می‌رفت و decision بدون
// پروژه می‌ماند (قلم‌ها در پنل هیچ مدیر پروژه‌ای دیده نمی‌شدند)
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
const chosenProjId = targetProjByLine.get(li.id);
return {
line_item_id: li.id,
status: chosen ? "chosen" : "rejected",
@@ -900,7 +896,7 @@ export const api = {
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
};
}),
item_projects: itemProjects,
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند
};
await patchData(`/quotes/${id}/accept`, payload);
// پروژه‌های سطح پیشنهاد با اتحاد پروژه‌های اقلام همگام می‌شوند