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:
@@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None:
|
|||||||
return {"id": str(project.id), "name": project.name}
|
return {"id": str(project.id), "name": project.name}
|
||||||
|
|
||||||
|
|
||||||
def decision_brief(d: ItemDecision) -> dict:
|
def decision_brief(d: ItemDecision, project: Project | None = None) -> dict:
|
||||||
"""Shape used inside expanded line items (P0-14)."""
|
"""Shape used inside expanded line items (P0-14)."""
|
||||||
return {
|
return {
|
||||||
"id": str(d.id),
|
"id": str(d.id),
|
||||||
"status": d.status,
|
"status": d.status,
|
||||||
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
||||||
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
||||||
|
"project": project_brief(project) if project is not None else None,
|
||||||
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
}
|
}
|
||||||
@@ -313,6 +314,7 @@ class _LineItemData:
|
|||||||
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
||||||
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
||||||
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
||||||
|
self.projects_by_id: dict[uuid.UUID, Project] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
||||||
@@ -375,6 +377,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
if "decision" in expand or "receipt" in expand:
|
if "decision" in expand or "receipt" in expand:
|
||||||
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
data.decisions = {d.line_item_id: d for d in decisions}
|
data.decisions = {d.line_item_id: d for d in decisions}
|
||||||
|
dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None}
|
||||||
|
missing_pids = dec_proj_ids - set(proj_map.keys())
|
||||||
|
if missing_pids:
|
||||||
|
extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all()
|
||||||
|
for p in extra_projs:
|
||||||
|
proj_map[p.id] = p
|
||||||
|
data.projects_by_id = proj_map
|
||||||
dec_ids = [d.id for d in decisions]
|
dec_ids = [d.id for d in decisions]
|
||||||
if dec_ids:
|
if dec_ids:
|
||||||
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
||||||
@@ -469,8 +478,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
||||||
)
|
)
|
||||||
decision = data.decisions.get(li.id)
|
decision = data.decisions.get(li.id)
|
||||||
|
dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None
|
||||||
|
d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None
|
||||||
if "decision" in expand:
|
if "decision" in expand:
|
||||||
d["decision"] = decision_brief(decision) if decision is not None else None
|
d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None
|
||||||
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
||||||
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
||||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||||
|
|||||||
@@ -279,13 +279,23 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a
|
|||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
||||||
|
"""Returns True if any warehouse receipt has been posted for the quote's items."""
|
||||||
|
count = await db.scalar(
|
||||||
|
select(func.count(ItemReceipt.id))
|
||||||
|
.join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id)
|
||||||
|
.join(LineItem, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0)
|
||||||
|
)
|
||||||
|
return (count or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||||
|
|
||||||
Only allowed while the quote is still offered — acceptance data
|
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||||
(decisions/receipts) would otherwise be destroyed.
|
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
@@ -294,6 +304,11 @@ async def replace_quote_children(
|
|||||||
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
||||||
field="status",
|
field="status",
|
||||||
)
|
)
|
||||||
|
if await quote_has_receipts(db, quote.id):
|
||||||
|
raise ConflictError(
|
||||||
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
|
field="status",
|
||||||
|
)
|
||||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
@@ -312,6 +327,12 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
||||||
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
||||||
|
|
||||||
|
if await quote_has_receipts(db, quote.id):
|
||||||
|
raise ConflictError(
|
||||||
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
|
field="status",
|
||||||
|
)
|
||||||
|
|
||||||
if new_status == "offered":
|
if new_status == "offered":
|
||||||
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
||||||
quote.accepted_date = None
|
quote.accepted_date = None
|
||||||
@@ -332,10 +353,11 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
chosen_item_termsheet_id=None,
|
chosen_item_termsheet_id=None,
|
||||||
adjusted_unit_price=None,
|
adjusted_unit_price=None,
|
||||||
adjusted_notes=None,
|
adjusted_notes=None,
|
||||||
|
project_id=None,
|
||||||
)
|
)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
# item_receipts are deliberately NOT deleted (contract P0-17).
|
# LineItemProjects (candidate projects) are preserved untouched!
|
||||||
elif new_status == "accepted": # arrived -> accepted
|
elif new_status == "accepted": # arrived -> accepted
|
||||||
quote.arrived_date = None
|
quote.arrived_date = None
|
||||||
|
|
||||||
@@ -692,7 +714,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
|
|||||||
|
|
||||||
|
|
||||||
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||||
async with db.begin():
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
if quote.status in ("accepted", "arrived"):
|
if quote.status in ("accepted", "arrived"):
|
||||||
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
||||||
@@ -712,6 +735,17 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||||
|
|
||||||
|
# clean up attachments in MinIO before cascade
|
||||||
|
from app.models.files import QuoteAttachment
|
||||||
|
from app.services import storage
|
||||||
|
|
||||||
|
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||||
|
for att in attachments:
|
||||||
|
try:
|
||||||
|
await storage.remove_object(att.object_key)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
|
||||||
|
|
||||||
await db.delete(quote)
|
await db.delete(quote)
|
||||||
return {
|
return {
|
||||||
"id": str(quote.id),
|
"id": str(quote.id),
|
||||||
@@ -722,6 +756,7 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
"events": len(events),
|
"events": len(events),
|
||||||
"decisions": len(decisions),
|
"decisions": len(decisions),
|
||||||
"receipts": len(receipts),
|
"receipts": len(receipts),
|
||||||
|
"attachments": len(attachments),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
daysInMonth,
|
daysInMonth,
|
||||||
gregorianToJalali,
|
gregorianToJalali,
|
||||||
@@ -14,11 +14,12 @@ import { cn } from "../lib/utils";
|
|||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
|
|
||||||
export interface JalaliDatePickerProps {
|
export interface JalaliDatePickerProps {
|
||||||
value: string | null; // ISO date (با time اختیاری)
|
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||||
onChange: (iso: string | null) => void;
|
onChange: (val: string | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayJalali(): [number, number, number] {
|
function todayJalali(): [number, number, number] {
|
||||||
@@ -28,6 +29,13 @@ function todayJalali(): [number, number, number] {
|
|||||||
|
|
||||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
|
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (m) {
|
||||||
|
const gy = parseInt(m[1], 10);
|
||||||
|
const gm = parseInt(m[2], 10);
|
||||||
|
const gd = parseInt(m[3], 10);
|
||||||
|
return gregorianToJalali(gy, gm, gd);
|
||||||
|
}
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
@@ -39,6 +47,12 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
|
|||||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
|
||||||
|
function buildDateOnly(jy: number, jm: number, jd: number): string {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
||||||
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
||||||
function nowTime(): { h: number; m: number } {
|
function nowTime(): { h: number; m: number } {
|
||||||
@@ -60,6 +74,7 @@ export function JalaliDatePicker({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
withTime = false,
|
withTime = false,
|
||||||
|
disabled = false,
|
||||||
}: JalaliDatePickerProps) {
|
}: JalaliDatePickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||||
@@ -74,11 +89,35 @@ export function JalaliDatePicker({
|
|||||||
return `${j} ${faNum(hh + ":" + mm)}`;
|
return `${j} ${faNum(hh + ":" + mm)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const j = isoToJalaliStr(value);
|
||||||
|
if (!withTime) {
|
||||||
|
setText(j);
|
||||||
|
} else {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
setText(j);
|
||||||
|
} else {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jParts = isoToJalali(value);
|
||||||
|
if (jParts) setCurJ(jParts);
|
||||||
|
}, [value, withTime]);
|
||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||||
function selectedLocalDate(): string | null {
|
function selectedLocalDate(): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
@@ -124,9 +163,11 @@ export function JalaliDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||||
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
|
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
|
||||||
onChange(isoWithTime);
|
onChange(val);
|
||||||
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
|
setText(
|
||||||
|
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
|
||||||
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,11 +186,10 @@ export function JalaliDatePicker({
|
|||||||
const pickToday = () => {
|
const pickToday = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [jy, jm, jd] = todayJalali();
|
const [jy, jm, jd] = todayJalali();
|
||||||
// بدون withTime فقط تاریخ امروز ثبت میشود — ساعت همیشه نیمهشب
|
const val = withTime
|
||||||
const isoWithTime = withTime
|
|
||||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||||||
: buildIso(jy, jm, jd, 0, 0);
|
: buildDateOnly(jy, jm, jd);
|
||||||
onChange(isoWithTime);
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
withTime
|
withTime
|
||||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
||||||
@@ -168,7 +208,19 @@ export function JalaliDatePicker({
|
|||||||
setText(v);
|
setText(v);
|
||||||
const iso = jalaliStrToIso(v);
|
const iso = jalaliStrToIso(v);
|
||||||
if (iso) {
|
if (iso) {
|
||||||
onChange(iso);
|
if (!withTime) {
|
||||||
|
const nums = String(v)
|
||||||
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
|
||||||
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
|
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||||
|
} else {
|
||||||
|
onChange(iso.slice(0, 10));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(iso);
|
||||||
|
}
|
||||||
const j = isoToJalali(iso);
|
const j = isoToJalali(iso);
|
||||||
if (j) setCurJ([j[0], j[1], j[2]]);
|
if (j) setCurJ([j[0], j[1], j[2]]);
|
||||||
}
|
}
|
||||||
@@ -178,15 +230,16 @@ export function JalaliDatePicker({
|
|||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
disabled={disabled}
|
||||||
|
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => !disabled && setOpen(true)}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{open && (
|
{open && !disabled && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export interface MilestoneRowProps {
|
|||||||
milestone: Milestone;
|
milestone: Milestone;
|
||||||
onChange: (m: Milestone) => void;
|
onChange: (m: Milestone) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="milestone-row">
|
<div className="milestone-row">
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
@@ -17,6 +18,7 @@ export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProp
|
|||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
placeholder="تاریخ"
|
placeholder="تاریخ"
|
||||||
className="w-36 shrink-0"
|
className="w-36 shrink-0"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<div className="ms-amount-wrap">
|
<div className="ms-amount-wrap">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -24,11 +26,14 @@ export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProp
|
|||||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||||
placeholder="مبلغ (تومان)"
|
placeholder="مبلغ (تومان)"
|
||||||
showWords
|
showWords
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
{!disabled && (
|
||||||
×
|
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface MultiSelectComboboxProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
||||||
@@ -27,6 +28,7 @@ export function MultiSelectCombobox({
|
|||||||
className,
|
className,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
|
disabled = false,
|
||||||
}: MultiSelectComboboxProps) {
|
}: MultiSelectComboboxProps) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -105,46 +107,56 @@ export function MultiSelectCombobox({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<div
|
<div
|
||||||
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
|
className={cn(
|
||||||
onClick={() => inputRef.current?.focus()}
|
"flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40",
|
||||||
|
disabled && "cursor-not-allowed opacity-60 bg-canvas/40",
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
{values.map((v) => (
|
{values.map((v) => (
|
||||||
<span key={v} className="spec-chip">
|
<span key={v} className="spec-chip">
|
||||||
<span className="max-w-[140px] truncate">{v}</span>
|
<span className="max-w-[140px] truncate">{v}</span>
|
||||||
<button
|
{!disabled && (
|
||||||
type="button"
|
<button
|
||||||
className="spec-chip-x"
|
type="button"
|
||||||
title="حذف"
|
className="spec-chip-x"
|
||||||
onClick={(e) => {
|
title="حذف"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setPendingRemove(v);
|
e.stopPropagation();
|
||||||
}}
|
setPendingRemove(v);
|
||||||
>
|
}}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
|
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted disabled:cursor-not-allowed",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={values.length ? "" : placeholder}
|
placeholder={values.length ? "" : placeholder}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onKeyDown(e);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && !disabled && (
|
||||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||||
{listCount === 0 && (
|
{listCount === 0 && (
|
||||||
<div className="px-3 py-1.5 text-[12px] text-muted">
|
<div className="px-3 py-1.5 text-[12px] text-muted">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface NumberInputProps {
|
|||||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||||
inputMode?: "numeric" | "decimal" | undefined;
|
inputMode?: "numeric" | "decimal" | undefined;
|
||||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
||||||
@@ -21,6 +22,7 @@ export function NumberInput({
|
|||||||
showWords = false,
|
showWords = false,
|
||||||
inputMode = "numeric",
|
inputMode = "numeric",
|
||||||
maxDecimals,
|
maxDecimals,
|
||||||
|
disabled = false,
|
||||||
}: NumberInputProps) {
|
}: NumberInputProps) {
|
||||||
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
||||||
const formatValue = (n: number): string => {
|
const formatValue = (n: number): string => {
|
||||||
@@ -62,7 +64,8 @@ export function NumberInput({
|
|||||||
<div className={cn("min-w-0", className)}>
|
<div className={cn("min-w-0", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
disabled={disabled}
|
||||||
|
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
|
|||||||
+19
-23
@@ -285,6 +285,8 @@ interface RawDecision {
|
|||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
chosen_item_termsheet_id?: string | null;
|
chosen_item_termsheet_id?: string | null;
|
||||||
|
project_id?: string | null;
|
||||||
|
project?: RawProject | null;
|
||||||
adjusted_unit_price?: number | null;
|
adjusted_unit_price?: number | null;
|
||||||
decided_at?: string | null;
|
decided_at?: string | null;
|
||||||
}
|
}
|
||||||
@@ -309,6 +311,7 @@ interface RawLineItem {
|
|||||||
termsheet_ids?: string[] | null;
|
termsheet_ids?: string[] | null;
|
||||||
accepted_termsheet_id?: string | null; // id ترمشیت پذیرفتهشده (task: خط آبی)
|
accepted_termsheet_id?: string | null; // id ترمشیت پذیرفتهشده (task: خط آبی)
|
||||||
projects?: RawProject[] | null; // پروژههای تخصیصیافته به این قلم
|
projects?: RawProject[] | null; // پروژههای تخصیصیافته به این قلم
|
||||||
|
target_project?: RawProject | null;
|
||||||
}
|
}
|
||||||
interface RawTermsheet {
|
interface RawTermsheet {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -517,6 +520,7 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
||||||
|
const targetProj = raw.target_project || raw.decision?.project || null;
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
material_id: raw.material?.id || "",
|
material_id: raw.material?.id || "",
|
||||||
@@ -537,6 +541,8 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
|||||||
quantity: a.quantity,
|
quantity: a.quantity,
|
||||||
})),
|
})),
|
||||||
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
|
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 {
|
function toDateOnly(v: string | null | undefined): string | null {
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/);
|
const str = String(v).trim();
|
||||||
if (m) return m[1];
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
||||||
const d = new Date(v);
|
const d = new Date(str);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
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) {
|
if (becomingAccepted) {
|
||||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||||
// تخصیص پروژه به هر قلم — نامها به id واقعی ترجمه میشوند
|
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
||||||
const itemProjects: { line_item_id: string; project_ids: string[] }[] = [];
|
const targetProjByLine = new Map<string, string>();
|
||||||
const allProjectIds: string[] = [];
|
|
||||||
const seenPids = new Set<string>();
|
|
||||||
for (const li of patch.line_items || []) {
|
for (const li of patch.line_items || []) {
|
||||||
const ids: string[] = [];
|
const tp = li.target_project;
|
||||||
for (const p of li.projects || []) {
|
if (tp && tp.name) {
|
||||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||||
if (!pid) continue;
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
ids.push(pid);
|
|
||||||
if (!seenPids.has(pid)) {
|
|
||||||
seenPids.add(pid);
|
|
||||||
allProjectIds.push(pid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemProjects.push({ line_item_id: li.id, project_ids: ids });
|
|
||||||
}
|
}
|
||||||
|
const allProjectIds = [...new Set([...targetProjByLine.values()])];
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
accepted_termsheets: acceptedTs.map((t) => ({
|
accepted_termsheets: acceptedTs.map((t) => ({
|
||||||
id: looksLikeUuid(t.id) ? t.id : undefined,
|
id: looksLikeUuid(t.id) ? t.id : undefined,
|
||||||
@@ -887,11 +887,7 @@ export const api = {
|
|||||||
item_decisions: (patch.line_items || []).map((li) => {
|
item_decisions: (patch.line_items || []).map((li) => {
|
||||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||||
const chosen = !li.rejected && idx >= 0;
|
const chosen = !li.rejected && idx >= 0;
|
||||||
// پروژهی واحدِ انتخابشده برای این قلم — مستقیم داخل decision میرود تا
|
const chosenProjId = targetProjByLine.get(li.id);
|
||||||
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
|
|
||||||
// نشان دهد؛ قبلاً فقط item_projects جداگانه میرفت و decision بدون
|
|
||||||
// پروژه میماند (قلمها در پنل هیچ مدیر پروژهای دیده نمیشدند)
|
|
||||||
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
|
|
||||||
return {
|
return {
|
||||||
line_item_id: li.id,
|
line_item_id: li.id,
|
||||||
status: chosen ? "chosen" : "rejected",
|
status: chosen ? "chosen" : "rejected",
|
||||||
@@ -900,7 +896,7 @@ export const api = {
|
|||||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
item_projects: itemProjects,
|
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||||
};
|
};
|
||||||
await patchData(`/quotes/${id}/accept`, payload);
|
await patchData(`/quotes/${id}/accept`, payload);
|
||||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
||||||
|
|||||||
@@ -40,20 +40,58 @@ export function specsLabel(specs: string[]): string {
|
|||||||
return specs.join(" · ");
|
return specs.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
|
/**
|
||||||
// برند و مشخصه فقط وقتی به نام اضافه میشوند که داخلش نیامده باشند (بدون تکرار).
|
* عنوان تمیز و بدون تکرار کالا:
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
|
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
||||||
const name = (m.name || "").trim();
|
*/
|
||||||
const specs = (m.specs || []).filter(Boolean);
|
export function formatMaterialTitle(
|
||||||
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
|
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||||
if (!name) {
|
extraSpecs?: string[],
|
||||||
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
|
): string {
|
||||||
|
if (!m) return "کالا";
|
||||||
|
const rawName = (m.name || "").trim();
|
||||||
|
const rawBrand = (m.brand || "").trim();
|
||||||
|
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||||
|
.map((s) => (s || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const normName = normalize(rawName);
|
||||||
|
const normBrand = normalize(rawBrand);
|
||||||
|
|
||||||
|
if (!rawName) {
|
||||||
|
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
||||||
|
if (rawBrand) return rawBrand;
|
||||||
|
if (rawSpecs.length) return rawSpecs.join(" · ");
|
||||||
|
return m.category && m.category !== "؟" ? m.category : "کالا";
|
||||||
}
|
}
|
||||||
const parts = [name];
|
|
||||||
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
|
let title = rawName;
|
||||||
const missing = specs.filter((s) => !name.includes(s));
|
if (normBrand && !normName.includes(normBrand)) {
|
||||||
if (missing.length) parts.push(`(${missing.join("، ")})`);
|
title = `${rawBrand} ${title}`;
|
||||||
return parts.join(" ");
|
}
|
||||||
|
|
||||||
|
const normTitle = normalize(title);
|
||||||
|
const distinctSpecs: string[] = [];
|
||||||
|
const seenNormSpecs = new Set<string>();
|
||||||
|
|
||||||
|
for (const s of rawSpecs) {
|
||||||
|
const ns = normalize(s);
|
||||||
|
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||||
|
seenNormSpecs.add(ns);
|
||||||
|
|
||||||
|
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
||||||
|
if (normTitle.includes(ns)) continue;
|
||||||
|
distinctSpecs.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distinctSpecs.length > 0) {
|
||||||
|
return `${title} · ${distinctSpecs.join(" · ")}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||||
|
return formatMaterialTitle(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||||
@@ -104,8 +142,21 @@ export function lineItemTotal(li: LineItem): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// پروژههای یک قلم — فقط پروژههای تخصیصیافته به همین قلم (بدون fallback به سطح پیشنهاد)
|
// پروژههای یک قلم:
|
||||||
export function itemProjects(_q: Quote, li: LineItem): string[] {
|
// - در وضعیت offered: تمام پروژههای کاندیدا (line_item_projects)
|
||||||
|
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
||||||
|
export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||||
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
|
if (li.target_project?.name) {
|
||||||
|
return [li.target_project.name];
|
||||||
|
}
|
||||||
|
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تکپروژه بودن
|
||||||
|
if (li.projects && li.projects.length === 1) {
|
||||||
|
const p = li.projects[0];
|
||||||
|
return [typeof p === "string" ? p : p.name];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +214,24 @@ export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: stri
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ستون پروژه
|
// ستون پروژه:
|
||||||
|
// در وضعیتهای پذیرفتهشده یا رسیده، فقط پروژههای هدف خرید اقلام نشان داده میشوند
|
||||||
export function projectCell(q: Quote): string {
|
export function projectCell(q: Quote): string {
|
||||||
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
|
const targets = (q.line_items || [])
|
||||||
|
.filter((li) => !li.rejected)
|
||||||
|
.map((li) =>
|
||||||
|
li.target_project?.name ||
|
||||||
|
(li.projects && li.projects.length === 1
|
||||||
|
? typeof li.projects[0] === "string"
|
||||||
|
? li.projects[0]
|
||||||
|
: li.projects[0].name
|
||||||
|
: null),
|
||||||
|
)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
const unique = [...new Set(targets)];
|
||||||
|
if (unique.length) return unique.join("، ");
|
||||||
|
}
|
||||||
if (q.projects && q.projects.length) return q.projects.join("، ");
|
if (q.projects && q.projects.length) return q.projects.join("، ");
|
||||||
return q.project || "—";
|
return q.project || "—";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ export interface LineItem {
|
|||||||
arrived_date: string | null; // تاریخ آخرین رسید
|
arrived_date: string | null; // تاریخ آخرین رسید
|
||||||
received_qty?: number | null; // مجموع رسیدهای ثبتشده (رسید جزیی) — null = هنوز دادهای نیست
|
received_qty?: number | null; // مجموع رسیدهای ثبتشده (رسید جزیی) — null = هنوز دادهای نیست
|
||||||
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
|
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
|
||||||
projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم
|
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||||
|
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||||
|
target_project_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// پیشنهاد خرید (اصلیترین entity)
|
// پیشنهاد خرید (اصلیترین entity)
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { api, nextDashedId } from "../../lib/api";
|
|||||||
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
||||||
import {
|
import {
|
||||||
activeItems,
|
activeItems,
|
||||||
|
formatMaterialTitle,
|
||||||
itemTermsheetIds,
|
itemTermsheetIds,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
|
||||||
materialSpecs,
|
|
||||||
specsLabel,
|
|
||||||
supplierById,
|
supplierById,
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { faNum, fmtNum } from "../../lib/formatters";
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
@@ -76,11 +74,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||||
projSel[li.id] = names.length
|
projSel[li.id] = li.target_project?.name
|
||||||
? names[0]
|
? li.target_project.name
|
||||||
: q.projects && q.projects.length
|
: names.length
|
||||||
? q.projects[0]
|
? names[0]
|
||||||
: q.project || "";
|
: q.projects && q.projects.length
|
||||||
|
? q.projects[0]
|
||||||
|
: q.project || "";
|
||||||
});
|
});
|
||||||
setItemProjectsSel(projSel);
|
setItemProjectsSel(projSel);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
@@ -219,17 +219,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهی هر قلم (تکی) + همگامسازی پروژههای سطح پیشنهاد
|
// پروژهی هر قلم (تکی هدف خرید) + همگامسازی پروژههای سطح پیشنهاد
|
||||||
const projUnion = new Set<string>();
|
const projUnion = new Set<string>();
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const name = itemProjectsSel[li.id];
|
||||||
if (name === undefined) return;
|
if (name === undefined) return;
|
||||||
li.projects = name ? [{ id: "", name }] : [];
|
li.target_project = name ? { id: "", name } : null;
|
||||||
|
// توجه: li.projects دستنخورده باقی میماند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||||
if (name) projUnion.add(name);
|
if (name) projUnion.add(name);
|
||||||
});
|
});
|
||||||
next.projects = [...projUnion];
|
if (projUnion.size > 0) {
|
||||||
next.project = next.projects[0] || "";
|
next.projects = [...projUnion];
|
||||||
|
next.project = next.projects[0] || "";
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
next.accepted_date = acceptDate || now;
|
next.accepted_date = acceptDate || now;
|
||||||
@@ -298,7 +301,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
{allItems.map((li) => {
|
{allItems.map((li) => {
|
||||||
const removed = removedItems.has(li.id);
|
const removed = removedItems.has(li.id);
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
const specText = specsLabel(materialSpecs(m));
|
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
||||||
<input
|
<input
|
||||||
@@ -314,8 +316,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 text-[13px]">
|
<span className="flex-1 text-[13px]">
|
||||||
{materialLabel(m)}
|
{formatMaterialTitle(m)}
|
||||||
{specText ? ` · ${specText}` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] text-muted">
|
<span className="text-[12px] text-muted">
|
||||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
{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))
|
.filter((li) => !removedItems.has(li.id))
|
||||||
.map((li) => {
|
.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
const specText = specsLabel(materialSpecs(m));
|
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||||
<div className="mb-1.5 text-[12.5px]">
|
<div className="mb-1.5 text-[12.5px]">
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{materialLabel(m)}
|
{formatMaterialTitle(m)}
|
||||||
</span>
|
</span>
|
||||||
{specText ? <span className="text-muted"> · {specText}</span> : ""}
|
|
||||||
<span className="ms-2 text-[11px] text-muted">
|
<span className="ms-2 text-[11px] text-muted">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { toast } from "sonner";
|
|||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import type { Quote } from "../../lib/types";
|
import type { Quote } from "../../lib/types";
|
||||||
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
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 { fmtNum } from "../../lib/formatters";
|
||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -86,7 +86,6 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{items.map((li) => {
|
{items.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
const specText = materialSpecs(m).join(" · ");
|
|
||||||
return (
|
return (
|
||||||
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
||||||
<input
|
<input
|
||||||
@@ -102,8 +101,7 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||||
{m.category} {m.brand}
|
{formatMaterialTitle(m)}
|
||||||
{specText ? ` · ${specText}` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="arrive-meta">
|
<span className="arrive-meta">
|
||||||
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2, AlertCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
import {
|
import {
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
useQuotes,
|
useQuotes,
|
||||||
useSuppliers,
|
useSuppliers,
|
||||||
useUpdateQuote,
|
useUpdateQuote,
|
||||||
|
useDeleteQuote,
|
||||||
useAddSupplier,
|
useAddSupplier,
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
@@ -66,11 +68,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const projects = useProjects();
|
const projects = useProjects();
|
||||||
const updateQuote = useUpdateQuote();
|
const updateQuote = useUpdateQuote();
|
||||||
|
const deleteQuote = useDeleteQuote();
|
||||||
const addSupplier = useAddSupplier();
|
const addSupplier = useAddSupplier();
|
||||||
const addMaterial = useAddMaterial();
|
const addMaterial = useAddMaterial();
|
||||||
|
|
||||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
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 [ready, setReady] = useState(false);
|
||||||
const [supplierName, setSupplierName] = useState("");
|
const [supplierName, setSupplierName] = useState("");
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
@@ -83,6 +94,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
|
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
|
||||||
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
|
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState("offered");
|
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(() => {
|
const specOptions = useMemo(() => {
|
||||||
@@ -204,6 +232,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
|
if (hasReceipts && status !== q.status) {
|
||||||
|
toast.error("این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
// تأمینکننده — موجود یا جدید
|
// تأمینکننده — موجود یا جدید
|
||||||
@@ -316,6 +349,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
rejected,
|
rejected,
|
||||||
arrived_date: row.origLi?.arrived_date ?? null,
|
arrived_date: row.origLi?.arrived_date ?? null,
|
||||||
projects: row.origLi?.projects ?? [],
|
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;
|
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.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.accepted_date = acceptedDate;
|
||||||
next.arrived_date = arrivedDate;
|
next.arrived_date = arrivedDate;
|
||||||
next.status = status as Quote["status"];
|
next.status = status as Quote["status"];
|
||||||
@@ -348,6 +383,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
li.rejected = false;
|
li.rejected = false;
|
||||||
li.arrived_date = null;
|
li.arrived_date = null;
|
||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
|
li.target_project = null;
|
||||||
|
li.target_project_id = null;
|
||||||
});
|
});
|
||||||
} else if (next.status === "accepted") {
|
} else if (next.status === "accepted") {
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
@@ -367,12 +404,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<DialogHeader>
|
||||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
||||||
</DialogHeader>
|
</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="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تأمینکننده</label>
|
<label>تأمینکننده</label>
|
||||||
@@ -416,7 +461,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>متریال</label>
|
<label>متریال</label>
|
||||||
<input
|
<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="متریال"
|
placeholder="متریال"
|
||||||
value={row.cat}
|
value={row.cat}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
@@ -425,7 +471,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>برند</label>
|
<label>برند</label>
|
||||||
<input
|
<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="برند"
|
placeholder="برند"
|
||||||
value={row.brand}
|
value={row.brand}
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
@@ -434,6 +481,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>مشخصه</label>
|
<label>مشخصه</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
|
disabled={hasReceipts}
|
||||||
values={row.specs}
|
values={row.specs}
|
||||||
onChange={(specs) => patchItem(row.key, { specs })}
|
onChange={(specs) => patchItem(row.key, { specs })}
|
||||||
options={specOptions}
|
options={specOptions}
|
||||||
@@ -443,7 +491,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>واحد</label>
|
<label>واحد</label>
|
||||||
<input
|
<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="واحد"
|
placeholder="واحد"
|
||||||
value={row.unit}
|
value={row.unit}
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
@@ -452,6 +501,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تعداد</label>
|
<label>تعداد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
disabled={hasReceipts}
|
||||||
value={row.qty}
|
value={row.qty}
|
||||||
onChange={(qty) => {
|
onChange={(qty) => {
|
||||||
patchItem(row.key, {
|
patchItem(row.key, {
|
||||||
@@ -465,6 +515,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>قیمت واحد</label>
|
<label>قیمت واحد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
disabled={hasReceipts}
|
||||||
value={row.price}
|
value={row.price}
|
||||||
onChange={(price) => {
|
onChange={(price) => {
|
||||||
patchItem(row.key, {
|
patchItem(row.key, {
|
||||||
@@ -479,6 +530,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>قیمت کل</label>
|
<label>قیمت کل</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
onChange={(t) => {
|
onChange={(t) => {
|
||||||
patchItem(row.key, {
|
patchItem(row.key, {
|
||||||
@@ -491,15 +543,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-end">
|
<div className="flex items-end justify-end">
|
||||||
<button
|
{!hasReceipts && (
|
||||||
type="button"
|
<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"
|
type="button"
|
||||||
title="حذف قلم"
|
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"
|
||||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
title="حذف قلم"
|
||||||
>
|
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
>
|
||||||
حذف
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
حذف
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* تخصیص termsheet / بازگرداندن */}
|
{/* تخصیص 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="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tsCards.map((card, idx) => (
|
{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
|
<input
|
||||||
type="checkbox"
|
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)}
|
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = new Set(row.tsDomIds);
|
const next = new Set(row.tsDomIds);
|
||||||
@@ -541,9 +596,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
{!hasReceipts && (
|
||||||
+ افزودن قلم
|
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
||||||
</Button>
|
+ افزودن قلم
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* termsheetها */}
|
{/* termsheetها */}
|
||||||
@@ -554,15 +611,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<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>
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
{!hasReceipts && (
|
||||||
×
|
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex flex-col gap-1.5">
|
<div className="mb-1 flex flex-col gap-1.5">
|
||||||
{card.milestones.map((ms, i) => (
|
{card.milestones.map((ms, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
milestone={ms}
|
milestone={ms}
|
||||||
|
disabled={hasReceipts}
|
||||||
onChange={(nextMs) =>
|
onChange={(nextMs) =>
|
||||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||||||
}
|
}
|
||||||
@@ -570,20 +630,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!hasReceipts && (
|
||||||
variant="line"
|
<Button
|
||||||
size="sm"
|
variant="line"
|
||||||
className="mt-1"
|
size="sm"
|
||||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
className="mt-1"
|
||||||
>
|
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||||
+ مرحله پرداخت
|
>
|
||||||
</Button>
|
+ مرحله پرداخت
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
{!hasReceipts && (
|
||||||
+ افزودن termsheet
|
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
||||||
</Button>
|
+ افزودن termsheet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فاکتورها */}
|
{/* فاکتورها */}
|
||||||
@@ -613,7 +677,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تاریخ انقضا</label>
|
<label>تاریخ انقضا</label>
|
||||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
|
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تاریخ پذیرش</label>
|
<label>تاریخ پذیرش</label>
|
||||||
@@ -628,7 +692,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<select
|
<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"
|
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}
|
value={status}
|
||||||
disabled={q.status === "arrived"}
|
disabled={hasReceipts}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
>
|
>
|
||||||
{statusOptionsFor(q.status).map((st) => (
|
{statusOptionsFor(q.status).map((st) => (
|
||||||
@@ -637,22 +701,68 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse">
|
||||||
<Button variant="slate" onClick={onClose}>
|
{q.status === "offered" ? (
|
||||||
انصراف
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 type { Attachment, Quote, Store } from "../../lib/types";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
|
formatMaterialTitle,
|
||||||
itemTermsheetIds,
|
itemTermsheetIds,
|
||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
|
||||||
materialSpecs,
|
|
||||||
refConverted,
|
refConverted,
|
||||||
specsLabel,
|
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
@@ -69,7 +67,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
const total = lineItemTotal(li);
|
const total = lineItemTotal(li);
|
||||||
const projs = itemProjects(q, li);
|
const projs = itemProjects(q, li);
|
||||||
const conv = isRejected ? null : refConverted(store, 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">
|
<div className="fb-name">
|
||||||
<Package className="me-1 inline h-3 w-3 text-muted" />
|
<Package className="me-1 inline h-3 w-3 text-muted" />
|
||||||
{materialLabel(m)}
|
{formatMaterialTitle(m)}
|
||||||
{specText ? ` · ${specText}` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="fb-meta">
|
<div className="fb-meta">
|
||||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
<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 { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
|
||||||
import type { Quote, Store } from "../../lib/types";
|
import type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
|
formatMaterialTitle,
|
||||||
isExpired,
|
isExpired,
|
||||||
itemsSummary,
|
itemsSummary,
|
||||||
materialById,
|
materialById,
|
||||||
materialSpecs,
|
|
||||||
projectCell,
|
projectCell,
|
||||||
quoteTotal,
|
quoteTotal,
|
||||||
supplierById,
|
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">
|
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
|
||||||
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
||||||
const m = materialById(store.materials, li.material_id);
|
const m = materialById(store.materials, li.material_id);
|
||||||
const specs = materialSpecs(m);
|
|
||||||
return (
|
return (
|
||||||
<span key={li.id} className="flex items-center gap-1">
|
<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" />
|
<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">
|
<span className="text-[10px] text-muted/70">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user