[CLAUDE] PurchaseEvaluation: ràng trường bắt buộc — form tạo hiện thiếu gì + checklist xanh/đỏ gửi duyệt (anh Kiệt FDC UAT R3)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m50s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m50s
Anh chốt "không chọn quy trình thì không cho tạo phiếu, ràng mấy cái rủi ro lại hết": - Form TẠO: nút "Tạo phiếu" disabled khi thiếu (Quy trình duyệt / Hạng mục / Dự án) + hint ĐỎ liệt kê rõ còn thiếu gì (✓ xanh khi đủ). Defense-in-depth với BE validator NotEmpty (S83). - Detail (D4): checklist xanh ✓ "đã khai" / đỏ ✗ "chưa khai" cho 4 trường bắt buộc gửi duyệt (NCC winner · giá>0 · ngân sách · bảng so sánh) hiện phía trên nút "Lưu & Gửi Duyệt" (mirror missingForApproval predicate, khớp annotation anh Kiệt "xanh=đã khai, đỏ=chưa khai"). FE-only 2 component × 2 app SHA-identical. Both build PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -224,6 +224,20 @@ export function PeDetailTabs({
|
|||||||
return missing
|
return missing
|
||||||
}, [evaluation])
|
}, [evaluation])
|
||||||
|
|
||||||
|
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
||||||
|
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||||
|
const submitChecklist = useMemo(() => {
|
||||||
|
const supplierOk = evaluation.selectedSupplierId != null
|
||||||
|
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||||
|
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||||
|
return [
|
||||||
|
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||||
|
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||||
|
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 },
|
||||||
|
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||||
|
]
|
||||||
|
}, [evaluation])
|
||||||
|
|
||||||
const canSubmitForApproval = mode === 'workspace'
|
const canSubmitForApproval = mode === 'workspace'
|
||||||
&& canEditPhase
|
&& canEditPhase
|
||||||
&& !readOnly
|
&& !readOnly
|
||||||
@ -411,9 +425,18 @@ export function PeDetailTabs({
|
|||||||
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
|
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
|
||||||
User 2026-05-07. */}
|
User 2026-05-07. */}
|
||||||
{mode === 'workspace' && canEditPhase && !readOnly && (
|
{mode === 'workspace' && canEditPhase && !readOnly && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-slate-200 bg-amber-50/40 px-5 py-2 text-[11px]">
|
||||||
|
<span className="font-semibold text-slate-600">Cần đủ để gửi duyệt:</span>
|
||||||
|
{submitChecklist.map(c => (
|
||||||
|
<span key={c.label} className={cn('inline-flex items-center gap-1', c.ok ? 'text-emerald-600' : 'font-semibold text-red-600')}>
|
||||||
|
{c.ok ? '✓' : '✗'} {c.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
|
{/* Xóa phiếu — CHỉ DangSoanThao (bản nháp). TraLai không cho xóa
|
||||||
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
|
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
|
||||||
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
|
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
|
||||||
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
|
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
|
||||||
@ -461,6 +484,7 @@ export function PeDetailTabs({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -139,7 +139,15 @@ export function PeWorkspaceCreateView({
|
|||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.workItemId && !!form.approvalWorkflowId && !create.isPending
|
// [D4/R3 anh Kiệt — "ràng mấy cái rủi ro lại hết"] Trường BẮT BUỘC để tạo phiếu — thiếu cái
|
||||||
|
// nào thì liệt kê cho user thấy (nút "Tạo phiếu" disabled + hint đỏ). KHÔNG chọn Quy trình
|
||||||
|
// duyệt → KHÔNG tạo được phiếu (anh chốt; defense-in-depth với BE validator NotEmpty S83).
|
||||||
|
const missingCreate = [
|
||||||
|
!form.approvalWorkflowId && 'Quy trình duyệt',
|
||||||
|
!form.workItemId && 'Hạng mục công việc (tên gói thầu)',
|
||||||
|
!form.projectId && 'Dự án',
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
const canSubmit = missingCreate.length === 0 && !!form.tenGoiThau && !create.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
@ -298,17 +306,26 @@ export function PeWorkspaceCreateView({
|
|||||||
ở Detail tabs sau khi tạo phiếu. */}
|
ở Detail tabs sau khi tạo phiếu. */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Action bar — [D4/R3 anh Kiệt] hint trái hiện RÕ còn thiếu gì (ràng rủi ro), nút phải. */}
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||||||
{onCancel && (
|
<div className="min-w-0 text-[11px]">
|
||||||
<Button variant="ghost" onClick={onCancel} className="text-xs">Hủy</Button>
|
{missingCreate.length > 0 ? (
|
||||||
)}
|
<span className="text-red-600">⚠ Chưa đủ để tạo phiếu — còn thiếu: <strong>{missingCreate.join(' · ')}</strong></span>
|
||||||
<Button
|
) : (
|
||||||
onClick={() => create.mutate()}
|
<span className="font-medium text-emerald-600">✓ Đủ thông tin bắt buộc — có thể tạo phiếu</span>
|
||||||
disabled={!canSubmit}
|
)}
|
||||||
>
|
</div>
|
||||||
{create.isPending ? 'Đang tạo…' : 'Tạo phiếu'}
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
</Button>
|
{onCancel && (
|
||||||
|
<Button variant="ghost" onClick={onCancel} className="text-xs">Hủy</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{create.isPending ? 'Đang tạo…' : 'Tạo phiếu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -224,6 +224,20 @@ export function PeDetailTabs({
|
|||||||
return missing
|
return missing
|
||||||
}, [evaluation])
|
}, [evaluation])
|
||||||
|
|
||||||
|
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
||||||
|
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||||
|
const submitChecklist = useMemo(() => {
|
||||||
|
const supplierOk = evaluation.selectedSupplierId != null
|
||||||
|
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||||
|
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||||
|
return [
|
||||||
|
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||||
|
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||||
|
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 },
|
||||||
|
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||||
|
]
|
||||||
|
}, [evaluation])
|
||||||
|
|
||||||
const canSubmitForApproval = mode === 'workspace'
|
const canSubmitForApproval = mode === 'workspace'
|
||||||
&& canEditPhase
|
&& canEditPhase
|
||||||
&& !readOnly
|
&& !readOnly
|
||||||
@ -411,9 +425,18 @@ export function PeDetailTabs({
|
|||||||
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
|
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
|
||||||
User 2026-05-07. */}
|
User 2026-05-07. */}
|
||||||
{mode === 'workspace' && canEditPhase && !readOnly && (
|
{mode === 'workspace' && canEditPhase && !readOnly && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-slate-200 bg-amber-50/40 px-5 py-2 text-[11px]">
|
||||||
|
<span className="font-semibold text-slate-600">Cần đủ để gửi duyệt:</span>
|
||||||
|
{submitChecklist.map(c => (
|
||||||
|
<span key={c.label} className={cn('inline-flex items-center gap-1', c.ok ? 'text-emerald-600' : 'font-semibold text-red-600')}>
|
||||||
|
{c.ok ? '✓' : '✗'} {c.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
|
{/* Xóa phiếu — CHỉ DangSoanThao (bản nháp). TraLai không cho xóa
|
||||||
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
|
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
|
||||||
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
|
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
|
||||||
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
|
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
|
||||||
@ -461,6 +484,7 @@ export function PeDetailTabs({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -139,7 +139,15 @@ export function PeWorkspaceCreateView({
|
|||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.workItemId && !!form.approvalWorkflowId && !create.isPending
|
// [D4/R3 anh Kiệt — "ràng mấy cái rủi ro lại hết"] Trường BẮT BUỘC để tạo phiếu — thiếu cái
|
||||||
|
// nào thì liệt kê cho user thấy (nút "Tạo phiếu" disabled + hint đỏ). KHÔNG chọn Quy trình
|
||||||
|
// duyệt → KHÔNG tạo được phiếu (anh chốt; defense-in-depth với BE validator NotEmpty S83).
|
||||||
|
const missingCreate = [
|
||||||
|
!form.approvalWorkflowId && 'Quy trình duyệt',
|
||||||
|
!form.workItemId && 'Hạng mục công việc (tên gói thầu)',
|
||||||
|
!form.projectId && 'Dự án',
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
const canSubmit = missingCreate.length === 0 && !!form.tenGoiThau && !create.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
@ -298,17 +306,26 @@ export function PeWorkspaceCreateView({
|
|||||||
ở Detail tabs sau khi tạo phiếu. */}
|
ở Detail tabs sau khi tạo phiếu. */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Action bar — [D4/R3 anh Kiệt] hint trái hiện RÕ còn thiếu gì (ràng rủi ro), nút phải. */}
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||||||
{onCancel && (
|
<div className="min-w-0 text-[11px]">
|
||||||
<Button variant="ghost" onClick={onCancel} className="text-xs">Hủy</Button>
|
{missingCreate.length > 0 ? (
|
||||||
)}
|
<span className="text-red-600">⚠ Chưa đủ để tạo phiếu — còn thiếu: <strong>{missingCreate.join(' · ')}</strong></span>
|
||||||
<Button
|
) : (
|
||||||
onClick={() => create.mutate()}
|
<span className="font-medium text-emerald-600">✓ Đủ thông tin bắt buộc — có thể tạo phiếu</span>
|
||||||
disabled={!canSubmit}
|
)}
|
||||||
>
|
</div>
|
||||||
{create.isPending ? 'Đang tạo…' : 'Tạo phiếu'}
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
</Button>
|
{onCancel && (
|
||||||
|
<Button variant="ghost" onClick={onCancel} className="text-xs">Hủy</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{create.isPending ? 'Đang tạo…' : 'Tạo phiếu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user