{/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
{(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
{canToggleProUrgent && (
)}
{canToggleCcmUrgent && (
)}
{/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
{evaluation.ceoApprovalThreshold != null && (
Giá trị gói: {fmtMoney(evaluation.winnerQuoteTotal)}đ
{' — '}
{evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
CCM duyệt là xong
) : (
Cần CEO duyệt
)}
(ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)
)}
)}
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */}
{(readOnly || mode !== 'workspace') && (
)}
{/* S77 [Bích Phượng hỏi / anh] — phiếu Trả lại ở chế độ XEM không có nút gửi
(readOnly → "Lưu & Gửi Duyệt" chỉ hiện khi Sửa). Banner hướng dẫn gửi lại
để NV khỏi lạc ("bấm nộp lần 2 chỗ nào"). */}
{evaluation.phase === PurchaseEvaluationPhase.TraLai && readOnly && (
⚠️ Phiếu đã bị trả lại để chỉnh sửa
Để gửi duyệt lại: ra danh sách → bấm ✏️ Sửa phiếu
này (biểu tượng bút chì) → điều chỉnh nội dung cần sửa → bấm “Lưu & Gửi Duyệt →” ở
cuối phiếu. Lý do trả lại xem ở mục “Lịch sử” bên dưới.
)}
{/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC
(tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC
tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên
hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
{/* Plan Q S23 t7 — Drop mx-5 banner, full-width Section padding to
align với ItemsTab header (button "+ Thêm hạng mục" right-aligned
KHÔNG còn lệch khỏi banner inset gap). */}
{approverEditMode && readOnly && (
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
)}
{mode === 'workspace' && (
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
)}
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
{evaluation.approvalWorkflowId
?
: }
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
User 2026-05-07. */}
{mode === 'workspace' && canEditPhase && !readOnly && (
<>
{/* 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
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
)}
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
>
)}
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
// Session 20 turn 11: padding responsive cho laptop màn nhỏ — px-3 trên xs
// (tiết kiệm ~16px width), bump px-5 từ sm+ trở lên.
return (
Ký bởi {existing?.userName ?? '—'} · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
)}
>
) : (
<>
)
}
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
//
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
//
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
// Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
// BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
// (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
//
// Counter fix turn 2: "Số bước duyệt" (= số Cấp / Step) KHÁC "số người duyệt trong
// 1 bước" (= tổng NV across Cấp, OR-of-N nên chỉ 1 NV/Cấp cần ký). Counter đúng
// hiển thị X/Y cấp đã duyệt + thông tin phụ tổng NV tham gia.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
if (!flow || flow.steps.length === 0) {
return (
Workflow chưa được cấu hình hoặc chưa có cấp duyệt nào.
)
}
function StepOpinionsBox({
stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
}: {
stepOrder: number
stepName: string
departmentName?: string | null
totalLevels: number // số Cấp (bước duyệt nhỏ trong Step)
totalApprovers: number // tổng NV tham gia (FYI — OR-of-N nên không cần ký hết)
signedLevels: number // số Cấp đã có ít nhất 1 NV ký
opinions: PeLevelOpinion[]
}) {
return (
Bước {stepOrder} — {stepName}
{departmentName && (
{departmentName}
)}
{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers} NV tham gia
setMoTa(e.target.value)}
placeholder="Phương án A: ..."
/>
{/* S59 vòng 5: field "Điều khoản thanh toán" GỠ khỏi inline-edit (anh chốt
"bỏ nốt ra luôn tất cả các form"). State paymentTerms giữ — save giữ nguyên
data cũ; phiếu đã nhập vẫn hiển thị read-only ở header info. */}
)
}
// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] =====
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => {
toast.success('Đã cập nhật đơn vị trúng thầu.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
const toggle = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
const busy = setWinners.isPending || peFetching
if (!canEdit) {
const winners = ev.suppliers.filter(s => s.isWinner)
return (
0
? ✓ {winners.map(s => s.supplierName).join(', ')}
: — (chưa chọn)}
/>
)
}
return (
a. NCC / TP được chọn
{ev.suppliers.length === 0 ? (
Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu.
) : (
{ev.suppliers.map(s => (
))}
)}
{/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
{winnerIds.length > 0 && (
Tổng chi phí đã chọn ({winnerIds.length} đơn vị):{fmtMoney(ev.winnerQuoteTotal)} đ
)}
{busy && (
Đang cập nhật trúng thầu + sync cột giá Section 4…
)}
)
}
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
// BE trả `ev.budgetSummary`. 2 block:
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// [C4b anh Kiệt FDC] Dòng "So sánh" = 0 (đề xuất ĐÚNG BẰNG ngân sách) → chữ thay "0 đ"
// cho đỡ khó hiểu. base>0 (có ngân sách thật) → "Bằng ngân sách"; base<=0 (phiếu trống) → "—".
const fmtCompareValue = (v: number, base: number): React.ReactNode =>
v === 0
? {base > 0 ? 'Bằng ngân sách' : '—'}
: {fmtVndSigned(v)}
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
/** [C4a anh Kiệt FDC] báo giá trị ĐANG GÕ lên cha mỗi keystroke → live-recompute. */
onLiveChange?: (v: number | null) => void
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(raw)
if (n === 0 && raw.trim() === '') return null
return allowNegative && isNeg ? -n : n
}
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial
return (
)
}
// [Mig 59] BudgetRow (1-cột flex) GỠ — Section B nay là
3 cột (DỰ ÁN|PRO|CCM)
// mirror Section A. Dòng/ô dùng BudgetSharedNumCell / BudgetColValue / BudgetColCompareCell.
// Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
// [S76] Ô tiền compact cho ma trận 3 cột — editable (input + nút Lưu) hoặc display.
// allowNegative cho "hiệu chỉnh tăng giảm" (nút ± đảo dấu). onSave nhận number|null.
function BudgetCell({ value, editable, allowNegative = false, saving, onSave }: {
value: number | null
editable: boolean
allowNegative?: boolean
saving: boolean
onSave: (v: number | null) => void
}) {
const [text, setText] = useState(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((value ?? 0) < 0)
useEffect(() => {
setText(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
setNeg((value ?? 0) < 0)
}, [value])
if (!editable) {
return value != null
? {fmtVndSigned(value)}
: —
}
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
}
const dirty = parse() !== value
return (
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const bs = ev.budgetSummary
// [S76] Khoá nút Lưu trong lúc pe-detail đang refetch (sau mỗi save) — đóng cửa-sổ
// stale-echo: tránh lưu 1 ô khi bs (server snapshot) chưa cập nhật → đè field anh-em
// (vd lưu PRO ban hành xong, lưu PRO hiệu chỉnh ngay sẽ echo bs.proInitial CŨ).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PUT /budget/pro — chỉ khi canEditPro. [S76] proInitial + proAdjust + proNote.
const proMut = useMutation({
mutationFn: async (body: { proInitialAmount: number | null; proAdjustmentAmount: number | null; proNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null; ccmNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
const adjustMut = useMutation({
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// [Mig 59 anh Kiệt FDC / CCM] PATCH /budget/ccm-period — "NS kỳ này" cột CCM (Section B
// tách 3 cột). Role-gate Admin|CostControl (BE Forbidden nếu khác). Absolute-set (null=
// clear). Gate FE qua bs.canEditCcm (BE-computed capability, mirror ô NS Block A).
const ccmPeriodMut = useMutation({
mutationFn: async (body: { ccmBudgetPeriodAmount: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget/ccm-period`, body),
onSuccess: () => { toast.success('Đã lưu NS kỳ này (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// ccmNote inline-edit state (mirror proNoteText) — [Mig anh Kiệt FDC]
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// [C4a anh Kiệt FDC] Live-recompute: giữ giá trị ĐANG GÕ của ô 3 (NS kỳ này) + ô 8 (giá
// trị TH dự kiến còn lại) ở state cục bộ → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ
// (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
// [S85 D3 anh Kiệt] draftRow3 default ← "Ngân sách Ban hành lần đầu" (bs.proInitialAmount)
// khi chưa nhập tay budgetPeriodAmount → row3 "tự nhảy". bs có thể null (guard !bs dưới) → optional.
const [draftRow3, setDraftRow3] = useState(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null)
const [draftRow8, setDraftRow8] = useState(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// [Mig 59] Live-recompute cột CCM: giữ "NS kỳ này (CCM)" đang gõ → row5/7/9 + So sánh
// cột CCM nhảy ngay. Default ← bs.initialAmount (NS Ban hành lần đầu CCM) khi chưa nhập.
const [draftCcmRow3, setDraftCcmRow3] = useState(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null)
useEffect(() => { setDraftCcmRow3(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null) }, [ev.ccmBudgetPeriodAmount, bs?.initialAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục để dùng ngân sách gói thầu.
)
}
// ===== Số liệu Excel =====
// [Mig 59] Bỏ `full` đơn-cột (bs.fullAmount cũ) — Section A+B nay tách 3 cột, mỗi cột
// dùng full RIÊNG = ban hành + hiệu chỉnh của cột đó (proFull / ccmFull dưới).
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0)
const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0)
// Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—".
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
// ===== Số THỰC-TẾ dùng CHUNG mọi cột (KHÔNG per-cột) =====
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
// [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về
// bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull =
// full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
const colCalc = (periodAmount: number | null, colFull: number, remaining: number | null) => {
const r3 = periodAmount ?? 0
const r5 = row1 + r3 // Lũy kế NS đã dùng (= 1 + 3 cột)
const r7 = colFull - r5 // NS còn lại (= full cột − 5 cột)
const r8 = remaining ?? r7 // Giá trị TH dự kiến còn lại (default = 7 cột)
const r9 = row4 + r8 // Giá trị tổng TH dự kiến (= 4 + 8 cột)
return {
r3, r5, r7, r8, r9,
cmpPeriod: r3 - row4, // So với NS kỳ này (= 3 cột − 4)
cmp56: r5 - row6, // So với NS (= 5 cột − 6)
cmpFull: colFull - r9, // So với NS full (= full cột − 9 cột)
}
}
// Cột PRO: kỳ này = budgetPeriodAmount (live draftRow3) · full = proFull · row8 editable
// (draftRow8 ↔ expectedRemainingAmount, drafter). Cột CCM: kỳ này = ccmBudgetPeriodAmount
// (live draftCcmRow3) · full = ccmFull · row8 = default (chưa có field CCM-remaining riêng).
const pro = colCalc(draftRow3, proFull, draftRow8)
const ccm = colCalc(draftCcmRow3, ccmFull, null)
// Cờ tô màu cảnh báo (cảnh báo MỀM, dựa cột PRO — luồng drafter chính). Hiện banner khi
// PRO HOẶC CCM vượt (đề xuất > NS kỳ này, hoặc tổng TH > NS full).
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > pro.r7
const anyOver = pro.cmpPeriod < 0 || pro.cmpFull < 0 || ccm.cmpPeriod < 0 || ccm.cmpFull < 0
return (
Tổng hợp ngân sách trình ký
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
(cmpFull<0) ở BẤT KỲ cột PRO/CCM. Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{anyOver && (
⚠️Vượt ngân sách — giá trị đề xuất NCC đang cao hơn ngân sách của gói thầu.
Phiếu vẫn lưu & gửi duyệt được, vui lòng kiểm tra lại số liệu trước khi trình.
)}
{/* ===== Block A — NGÂN SÁCH gói thầu: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[S76 anh Kiệt FDC]
viền ô đầy đủ giống file Excel. Mỗi phòng nhập+điều
chỉnh cột CHÍNH mình (role-gate): PRO→cột PRO (canEditPro) · CCM→cột CCM
(canEditCcm) · DỰ ÁN hiển-thị-only (—, sau có người dự án nhập). Full mỗi cột
= ban hành + hiệu chỉnh. */}
A. Ngân sách (gói thầu / gói vật tư)
Khoản mục
Dự án
PRO
CCM
{/* Ngân sách full (= ban hành + hiệu chỉnh) mỗi cột */}
{/* ===== Block B — THỰC HIỆN: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[Mig 59 anh Kiệt FDC / CCM] Mirror style Block A. Mỗi cột tính ĐỘC-LẬP theo "NS kỳ
này" + "full" của cột đó (pro/ccm = colCalc). Số thực-tế (1/2/4/6) dùng CHUNG mọi
cột. "NS kỳ này": PRO editable drafter (VndInlineEdit + /budget-adjust hiện có) ·
CCM editable canEditCcm (VndInlineEdit + PATCH /budget/ccm-period) · DỰ ÁN "—". */}
B. Thực hiện
Khoản mục
Dự án
PRO
CCM
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
1. Ngân sách trình duyệt trước
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
2. Kỳ trước đã chọn thầu
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
3. Ngân sách - kỳ này
% so với NS full của cột
—
{/* PRO — drafter giữ luồng /budget-adjust hiện có */}
{/* Ghi chú PRO — vì sao chọn Min/Max. Editable khi canEditPro (Textarea + nút Lưu),
read-only hiện text khi có note. Lưu qua proPriceMut (echo min/max hiện tại). */}
{(canEditPro || ev.proSuggestedPriceNote) && (
{/* Ghi chú CCM — vì sao 1 giá. Editable khi canEditCcm, read-only hiện text khi có note.
Lưu qua ccmPriceMut (echo ccmPrice hiện tại). */}
{(canEditCcm || ev.ccmSuggestedPriceNote) && (
Ghi chú (CCM)
{canEditCcm ? (
) : (
{ev.ccmSuggestedPriceNote}
)}
)}
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
{approved && (
)
}
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
{label}
{value}
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
)
}
// Session 20 Chunk B: SuppliersTab function bỏ — NCC list giờ render nested
// trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
// vẫn giữ vì HangMucCard call lại.
// Session 20 turn 8: Dialog thêm NCC mới — khi gọi từ HangMucCard (có detailId)
// thì input "Số tiền" hiển thị + sequential POST: tạo supplier → tạo quote
// cho hạng mục đó. detailId optional cho call site khác trong tương lai.
function AddSupplierDialog({ evaluationId, detailId, onClose }: {
evaluationId: string
detailId?: string
onClose: () => void
}) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
thanhTien: 0,
})
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
const showQuote = !!detailId
// S59 UAT "Không tự thêm dc tên NTP mới" — anh chốt mở POST /suppliers cho mọi
// user đăng nhập (Sửa/Xóa vẫn Admin/CatalogManager). Tạo xong auto-select vào phiếu.
const [showNew, setShowNew] = useState(false)
const [newSup, setNewSup] = useState({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
const createSup = useMutation({
mutationFn: async () => (await api.post<{ id: string }>('/suppliers', {
code: newSup.code.trim(),
name: newSup.name.trim(),
type: newSup.type,
phone: newSup.phone.trim() || null,
email: newSup.email.trim() || null,
})).data,
onSuccess: async created => {
toast.success('Đã tạo NCC mới vào danh mục.')
await qc.invalidateQueries({ queryKey: ['all-suppliers'] })
setForm(prev => ({ ...prev, supplierId: created.id, contactPhone: newSup.phone.trim(), contactEmail: newSup.email.trim() }))
setShowNew(false)
setNewSup({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
},
onError: e => toast.error(getErrorMessage(e)),
})
const mut = useMutation({
mutationFn: async () => {
// Step 1: tạo NCC tham gia (PE.Suppliers row)
const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, {
supplierId: form.supplierId,
displayName: form.displayName,
contactName: form.contactName,
contactEmail: form.contactEmail,
contactPhone: form.contactPhone,
paymentTermText: form.paymentTermText,
note: form.note,
})
const newSupplierRowId = res.data.id
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0)
if (detailId && form.thanhTien > 0) {
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: newSupplierRowId,
bgVat: 0,
chuaVat: 0,
thanhTien: form.thanhTien,
note: '',
isSelected: false,
})
}
},
onSuccess: () => {
toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
onClose()
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
)
}
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
supplierId: row.supplierId,
displayName: row.displayName ?? '',
contactName: row.contactName ?? '',
contactEmail: row.contactEmail ?? '',
contactPhone: row.contactPhone ?? '',
paymentTermText: row.paymentTermText ?? '',
note: row.note ?? '',
})
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
const mut = useMutation({
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
)
}
// ===== Tab: Hạng mục + Báo giá (Session 20 — nested cards layout) =====
// Mỗi hạng mục = 1 card với expand panel chứa NCC tham gia inline grid.
// Replace bảng matrix grid (hạng mục × NCC) cũ — user demo 1 hạng mục.
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState(null)
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
return (
{ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
{!readOnly && ' — mở hạng mục để thêm NCC + nhập báo giá.'}
{/* S59 vòng 6 (anh chốt "bỏ luôn cái nút thêm hạng mục"): 1 phiếu = 1 hạng mục
chọn từ header (S57bis/S58) — hạng mục đầu auto-seed khi tạo phiếu, nút thêm
hạng mục thứ 2+ sai mô hình. AddItemDialog giữ (dead) để flip lại dễ nếu cần. */}
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
{!readOnly && (
)}
{/* Expand panel — NCC tham gia + báo giá inline */}
{expanded && (
NCC tham gia ({ev.suppliers.length})
{!readOnly && (
)}
{ev.suppliers.length === 0 ? (
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
) : (
{/* S59 UAT vòng 3: "thêm file giao diện bị thay đổi không cân xứng" — auto-layout
để cell File (chip tên dài) phình + bóp dọc cột NCC. Fix: table-fixed + width
từng cột (chip file/email có truncate sẵn — kích hoạt khi cell khóa width);
min-w để panel hẹp thì scroll ngang (wrapper overflow-x-auto) thay vì bóp nát. */}