Wizard: - failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing - material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows Accept flow: - quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: []) Dates/time: - JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis. Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import { Link } from "react-router-dom";
|
|
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react";
|
|
import type { Quote, Store } from "../../lib/types";
|
|
import {
|
|
isExpired,
|
|
itemsSummary,
|
|
materialById,
|
|
materialLabel,
|
|
projectCell,
|
|
quoteTotal,
|
|
supplierById,
|
|
termsheetSummary,
|
|
unitWarning,
|
|
} from "../../lib/quoteUtils";
|
|
import { STATUS_LABEL } from "../../lib/constants";
|
|
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
|
|
import { Badge } from "../../components/ui/badge";
|
|
import { cn } from "../../lib/utils";
|
|
import { QuoteDetail } from "./QuoteDetail";
|
|
|
|
export interface QuoteCardProps {
|
|
store: Store;
|
|
q: Quote;
|
|
expanded: boolean;
|
|
onToggleExpand: () => void;
|
|
onAccept: () => void;
|
|
onEdit: () => void;
|
|
}
|
|
|
|
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<div className={cn("min-w-0", className)}>
|
|
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
|
|
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// کارت پیشنهاد خرید — بهینهشده برای کاربری سرپا با موبایل
|
|
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
|
const sup = supplierById(store.suppliers, q.supplier_id);
|
|
const expired = isExpired(q);
|
|
const total = quoteTotal(q);
|
|
const pc = priceCell(total);
|
|
const ts = termsheetSummary(store, q);
|
|
const unitWarns = unitWarning(store, q);
|
|
const summary = itemsSummary(store, q);
|
|
|
|
// ستون / دکمه پذیرش
|
|
let acceptedCell: React.ReactNode;
|
|
if (q.status === "offered") {
|
|
acceptedCell = (
|
|
<button
|
|
onClick={onAccept}
|
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
<span>پذیرش</span>
|
|
</button>
|
|
);
|
|
} else if (q.accepted_date) {
|
|
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
|
} else {
|
|
acceptedCell = <span className="text-muted">—</span>;
|
|
}
|
|
|
|
// وضعیت رسید کالا
|
|
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
|
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
|
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
|
const fulfilledItems = activeItems.filter(
|
|
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
|
).length;
|
|
const anyReceived = receivedSum > 0;
|
|
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
|
|
|
// نشان پیشرفت تکمیل
|
|
let fulfillmentBadge: React.ReactNode = null;
|
|
if (q.status === "accepted" || q.status === "arrived") {
|
|
if (fullyDelivered || q.status === "arrived") {
|
|
fulfillmentBadge = (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10.5px] font-bold text-emerald-700 sm:text-[11px]">
|
|
✓ تکمیل
|
|
</span>
|
|
);
|
|
} else if (anyReceived) {
|
|
fulfillmentBadge = (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10.5px] font-bold text-amber-700 sm:text-[11px]">
|
|
رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
|
|
</span>
|
|
);
|
|
} else {
|
|
fulfillmentBadge = (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10.5px] font-bold text-blue-700 sm:text-[11px]">
|
|
⏳ در راه
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
let arrivedCell: React.ReactNode;
|
|
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
|
if (!li.arrived_date) return acc;
|
|
const t = new Date(li.arrived_date).getTime();
|
|
if (Number.isNaN(t)) return acc;
|
|
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
|
}, null);
|
|
|
|
if (q.status === "accepted") {
|
|
if (q.arrived_date) {
|
|
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
|
} else if (receivedSum > 0) {
|
|
arrivedCell = (
|
|
<span className="tabular-nums">
|
|
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
|
|
</span>
|
|
);
|
|
} else {
|
|
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
|
|
}
|
|
} else if (q.status === "arrived") {
|
|
arrivedCell = (
|
|
<span className="tabular-nums">
|
|
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
|
|
</span>
|
|
);
|
|
} else {
|
|
arrivedCell = <span className="text-muted">—</span>;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
|
|
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
|
expanded && "border-accent/60 ring-1 ring-accent/20",
|
|
)}
|
|
>
|
|
{/* سربرگ کارت */}
|
|
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
|
{sup.name.slice(0, 1)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<Link
|
|
to={`/suppliers/${q.supplier_id}`}
|
|
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
|
>
|
|
{sup.name}
|
|
</Link>
|
|
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
|
</div>
|
|
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
|
{fulfillmentBadge ?? (
|
|
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
|
{STATUS_LABEL[q.status]}
|
|
</Badge>
|
|
)}
|
|
<button
|
|
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
|
onClick={onEdit}
|
|
title="ویرایش پیشنهاد"
|
|
aria-label="ویرایش"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
<span className="hidden sm:inline">ویرایش</span>
|
|
</button>
|
|
<button
|
|
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
|
onClick={onToggleExpand}
|
|
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
|
aria-expanded={expanded}
|
|
>
|
|
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
|
{/* ۱. پروژه — تمامعرض در موبایل */}
|
|
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
<span className="truncate">
|
|
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
|
</span>
|
|
</div>
|
|
</InfoCell>
|
|
|
|
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
|
{summary.count > 1 ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="font-bold text-xs text-ink sm:text-sm">
|
|
{fmtNum(summary.count)} قلم کالا
|
|
</span>
|
|
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
|
{store.quotes
|
|
.find((x) => x.id === q.id)
|
|
?.line_items?.filter((li) => !li.rejected)
|
|
.slice(0, 3)
|
|
.map((li) => {
|
|
const m = materialById(store.materials, li.material_id);
|
|
return (
|
|
<div
|
|
key={li.id}
|
|
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
|
>
|
|
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
|
• {materialLabel(m)}
|
|
</span>
|
|
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
|
{fmtNum(li.quantity)}{" "}
|
|
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{(q.line_items?.length || 0) > 3 && (
|
|
<span className="pt-0.5 text-[10px] text-muted">
|
|
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
|
<span className="break-words">{summary.text}</span>
|
|
</div>
|
|
)}
|
|
|
|
{unitWarns.map((w) => (
|
|
<Link
|
|
key={w.unit}
|
|
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
|
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
|
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
|
>
|
|
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
|
واحد جدید: {w.unit}
|
|
</Link>
|
|
))}
|
|
</InfoCell>
|
|
|
|
<InfoCell label="پروژه">
|
|
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
<span className="truncate">
|
|
{(() => {
|
|
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
|
if (q.status === "accepted" || q.status === "arrived") {
|
|
// فقط پروژهی هدف پذیرفتهشده — همان منطق itemProjects در وضعیت accepted
|
|
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
|
|
return targetProj?.name ?? projectCell(q);
|
|
}
|
|
// وضعیت offered: همهی پروژههای کاندیدا از line_items (همان منطق itemProjects)
|
|
const names = new Set<string>();
|
|
activeItems.forEach((li) =>
|
|
(li.projects || []).forEach((p) => {
|
|
const name = typeof p === "string" ? p : p.name;
|
|
if (name) names.add(name);
|
|
})
|
|
);
|
|
if (names.size > 0) return [...names].join("، ");
|
|
return projectCell(q);
|
|
})()}
|
|
</span>
|
|
</span>
|
|
</InfoCell>
|
|
|
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
|
{pc ? (
|
|
<div>
|
|
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
|
|
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
|
</div>
|
|
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
|
</div>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</InfoCell>
|
|
|
|
{/* ۴. شرایط معامله */}
|
|
<InfoCell label="شرایط معامله" className="col-span-1">
|
|
{ts.title !== "—" ? (
|
|
<div>
|
|
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
|
{ts.title}
|
|
</div>
|
|
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted">—</span>
|
|
)}
|
|
</InfoCell>
|
|
</div>
|
|
|
|
{/* تاریخها و اکشنها */}
|
|
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
|
<span className="inline-flex items-center gap-1.5 truncate">
|
|
<Package className="h-3 w-3 shrink-0" />
|
|
<span>تاریخ:</span>
|
|
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
|
|
</span>
|
|
|
|
<span className="inline-flex items-center gap-1.5 truncate">
|
|
<CreditCard className="h-3 w-3 shrink-0" />
|
|
<span>اعتبار:</span>
|
|
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
|
{fmtDate(q.expiry_date)}
|
|
</b>
|
|
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
|
|
</span>
|
|
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span>پذیرش:</span>
|
|
{acceptedCell}
|
|
</span>
|
|
|
|
<span className="inline-flex items-center gap-1.5 truncate">
|
|
<span>رسید:</span>
|
|
{arrivedCell}
|
|
</span>
|
|
</div>
|
|
|
|
{/* جزئیات تکمیلی بازشونده */}
|
|
{expanded && <QuoteDetail store={store} q={q} />}
|
|
</div>
|
|
);
|
|
} |