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).
145 lines
6.5 KiB
TypeScript
145 lines
6.5 KiB
TypeScript
import { Paperclip } from "lucide-react";
|
||
import { faNum } from "../../lib/formatters";
|
||
import { groupCommas } from "../../lib/numutil";
|
||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||
|
||
export interface Step5Props {
|
||
quoteDate: string | null;
|
||
onQuoteDate: (iso: string | null) => void;
|
||
expiryDate: string | null;
|
||
onExpiryDate: (iso: string | null) => void;
|
||
notes: string;
|
||
onNotes: (v: string) => void;
|
||
proforma: File | null;
|
||
onProforma: (f: File | null) => void;
|
||
// خلاصهی بازبینی
|
||
summary: {
|
||
supplierName: string;
|
||
topicName: string;
|
||
projects: string[];
|
||
itemCount: number;
|
||
tsCount: number;
|
||
grandTotal: number;
|
||
isOfficial: boolean;
|
||
orphanCount: number;
|
||
};
|
||
}
|
||
|
||
// مرحله ۵ — تاریخ، یادداشت، پیشفاکتور و بازبینی نهایی
|
||
export function Step5Review({
|
||
quoteDate,
|
||
onQuoteDate,
|
||
expiryDate,
|
||
onExpiryDate,
|
||
notes,
|
||
onNotes,
|
||
proforma,
|
||
onProforma,
|
||
summary,
|
||
}: Step5Props) {
|
||
const rows: [string, string][] = [
|
||
["تأمینکننده", summary.supplierName],
|
||
["موضوع", summary.topicName],
|
||
["پروژهها", summary.projects.join("، ") || "—"],
|
||
["تعداد اقلام", groupCommas(summary.itemCount, true)],
|
||
["تعداد termsheet", groupCommas(summary.tsCount, true)],
|
||
["جمع کل تقریبی", groupCommas(summary.grandTotal, true) + " تومان"],
|
||
["فاکتور رسمی", summary.isOfficial ? "بله" : "خیر"],
|
||
["پیشفاکتور", proforma ? proforma.name : "—"],
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<h3 className="mb-3 text-[15px] font-bold">
|
||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۵</span>
|
||
تاریخ، یادداشت و بازبینی نهایی
|
||
</h3>
|
||
|
||
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
||
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
|
||
<label>تاریخ پیشنهاد</label>
|
||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||
<div className="hint">پیشفرض امروز</div>
|
||
</div>
|
||
|
||
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
|
||
<label>تاریخ انقضای پیشنهاد</label>
|
||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||
</div>
|
||
|
||
<div className="field col-span-12" data-field="notes">
|
||
<label>یادداشت (اختیاری)</label>
|
||
<textarea
|
||
rows={2}
|
||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
placeholder="توضیحات اضافی درباره تماس یا توافق..."
|
||
value={notes}
|
||
onChange={(e) => onNotes(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="field col-span-12" data-field="proforma">
|
||
<label>پیشفاکتور (اختیاری)</label>
|
||
{proforma ? (
|
||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-xs">
|
||
<div className="flex items-center gap-2 truncate text-gray-700">
|
||
<Paperclip className="h-4 w-4 text-blue-600 shrink-0" />
|
||
<span className="font-medium truncate">{proforma.name}</span>
|
||
<span className="text-[11px] text-gray-400 shrink-0">
|
||
({Math.round(proforma.size / 1024)} کیلوبایت)
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||
title="حذف فایل"
|
||
onClick={() => onProforma(null)}
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
/* دکمه آپلود مینیمال با حاشیه خطچین ملایم */
|
||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-gray-300 bg-gray-50/50 px-4 py-2 text-xs font-medium text-gray-600 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all">
|
||
<Paperclip className="h-4 w-4 text-gray-400" />
|
||
<span>انتخاب یا پیوست فایل پیشفاکتور</span>
|
||
<input
|
||
type="file"
|
||
className="hidden"
|
||
accept=".pdf,.png,.jpg,.jpeg,.xlsx,.xls,.docx"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) onProforma(f);
|
||
}}
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||
<h4 className="mb-2 text-[13.5px] font-bold">خلاصهی پیشنهاد پیش از ذخیره</h4>
|
||
{rows.map(([label, val]) => (
|
||
<div key={label} className="review-row">
|
||
<span className="r-label">{label}</span>
|
||
<span className="r-val">{val}</span>
|
||
</div>
|
||
))}
|
||
{summary.orphanCount > 0 && (
|
||
<div className="review-row">
|
||
<span className="r-label">هشدار</span>
|
||
<span className="r-val review-warn">
|
||
{faNum(summary.orphanCount)} قلم به هیچ termsheet وصل نیست -- در ذخیرهسازی بهطور خودکار به اولین termsheet وصل میشود
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
} |