[CLAUDE] Contract: KHKK UAT S175 dot-E — rut gon man theo anh khoanh do (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m28s

Anh review truc tiep tren prod, khoanh do tung cho. Land dung nhung gi anh chi:

=== GO (anh: "Bo di" / "Bo het di" / "Bo may dong nay di") ===
· 2 o TONG (Sigma de xuat / Sigma duyet) — chinh anh chon "hien 2 tong rieng" khi em hoi
  o vong AskUser truoc, nhin that thi thua => go. Giu 1 dong vet trong ma de lan sau khong
  ai dung lai tuong la thieu.
· 3 dong chu-thich duoi bang gia (SNAPSHOT / gan hang muc / dong them tay). Noi dung khong
  mat: rang-buoc "phai gan hang muc moi trinh duyet" van do submit-guard BE chan THAT, va o
  Hang muc o muc 1 da dem "con N dong chua gan".
· Muc 1: GO **Phong ban · Quy trinh · Han xu ly** — Quy trinh van xem duoc o PANEL 3, go o
  day la bo cho ve TRUNG chu khong mat thong tin.
· Muc 1: GO **Link ho so · Ghi chu** khoi o xem (form Sua van con 2 truong nay).
· Chip "Giai doan 2 · <ten nhom dai>" o header — ten nhom da co trong tieu de ("— N2") va
  dai 4 giai doan da chi ro dang o GD2 => chip la lan ke thu BA.
· Dong "Dang loc — danh sach lay thang tu may chu…" + dai dau cot "DANH SACH KE HOACH".
  🔸 Dai dau cot chinh la thu em THEM o dot B de chua "cot cut dau" — anh xem that roi bao
    bo. Ghi lai de khong tu dung lai.

=== THEM / DOI ===
· Muc 1 +o **Hang muc**: 1 phieu nay duoc N hang muc => liet ke DUY-NHAT theo thu tu dong,
  kem badge amber "con N dong chua gan" (dong chua gan KHONG bia ten).
· Header phieu +**ma phieu PE nguon** +**NCC trung thau** (anh: "Dua len tren phieu — NCC").
  NCC lay duy-nhat tu lines nen lien-danh N NCC liet ke het.
· Cho tai file: be DUNG khuon Duyet NCC (`PeDetailTabs.tsx:3527-3531` + `:3601-3608`) —
  dong chu mo-ta o TREN, nut vien DUT nen brand-nhat o DUOI danh sach, thay nut dac o goc
  header. Nut vien dut doc ra la "cho tha them vao"; nut dac o header doc ra la "hanh dong
  cua ca khoi" — day la cai thu nhat. +`accept` bo loc dinh dang y PE.
· Cay panel Thao tac: 2 cap Du an > Hang muc nay GAP/MO duoc (anh: "Cho hien thi theo cay").
  🔴 Dung tap-GAP (khong phai tap-MO) => mac dinh MO HET: du an/hang muc moi xuat hien sau
    nay tu mo, khong bi an im lang vi chua ai bam mo bao gio.

=== CON NO (chua land, ghi ra de khong roi) ===
🔴 "So tien CCM va PRO duyet" dua len header — CHUA lam duoc: `KhkkDetailDto` hien 0 truong
  ngan sach (do: grep -c ccm khkk.ts = 0, control-duong pro = 50) => can BE tra them.
🔴 Gom nut thao tac: go "+ Lap ke hoach" (header) · "Sua" (muc 1) · khoi "HANH DONG" (panel 3)
  · banner "Dang xem ke hoach" => THAY bang 1 THANH NUT DAY khuon PE workspace
  (Xoa phieu | Luu | Luu & Gui Duyet).
🔴 "Bo cai dam ke ben" — dai doc giua panel 1 va panel 2, chua dinh vi duoc.
🔴 Danh sach hien theo ngay thang > du an > hang muc > phieu PE + phieu KHKK tuong ung.
🔴 CHUA soi mat prod 375/1366/1440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 21:21:08 +07:00
parent 06918622a6
commit 32cc8d5cc6
8 changed files with 296 additions and 220 deletions

View File

@ -361,6 +361,28 @@ export function KhkkDetailContent({
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 px-5 py-3">
<span className="font-mono text-sm font-semibold text-brand-800">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
<span className="min-w-0 flex-1 truncate text-sm text-slate-700">{plan.peTenGoiThau ?? '—'}</span>
{/* [S175 — anh: "Đưa lên trên phiếu — NCC"] Mã phiếu PE nguồn + NCC trúng thầu dời từ
mục 1 lên ĐÂY. Đây là 2 thứ người xem cần biết ngay khi mở phiếu, không phải cuộn
xuống bảng mới thấy. NCC lấy DUY-NHẤT từ `lines` (liên danh N NCC ⇒ liệt kê hết).
🔴 CÒN NỢ: "số tiền CCM và PRO duyệt" anh yêu cầu CHƯA đưa lên được — `KhkkDetailDto`
hiện 0 trường ngân sách (đo: `grep -c ccm khkk.ts` = 0); cần BE trả thêm. */}
{plan.peMaPhieu && (
<span
className="inline-flex items-center rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-600"
title="Phiếu Duyệt NCC nguồn"
>
{plan.peMaPhieu}
</span>
)}
{[...new Set(plan.lines.map(l => l.supplierName).filter(Boolean))].map(n => (
<span
key={n as string}
className="inline-flex max-w-[16rem] items-center truncate rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700"
title={`NCC trúng thầu: ${n}`}
>
{n}
</span>
))}
{plan.approvalGroup != null && (
<span
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2 py-0.5 text-[11px] font-semibold text-violet-800"
@ -391,27 +413,42 @@ export function KhkkDetailContent({
) : undefined
}
>
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
🔸 Quy trình vẫn xem được ở PANEL 3 (`KhkkWorkflowPanel` đầu khối) — gỡ ở đây
là bỏ chỗ vẽ TRÙNG, không mất thông tin.
· "Bỏ đi" → GỠ **Link hồ sơ · Ghi chú** khỏi ô xem (form Sửa vẫn còn, xem dưới)
· "Thêm hạng mục vào đây" → +ô **Hạng mục** ngay cạnh Dự án
· "Đưa lên trên phiếu" → **NCC** + mã phiếu PE dời lên dải header (xem `:361`) */}
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
<Field label="Phiếu Duyệt NCC nguồn">
<span className="font-mono text-sm">{plan.peMaPhieu ?? '—'}</span>
</Field>
<Field label="Dự án" value={plan.projectName ?? '—'} />
<Field label="Phòng ban" value={plan.departmentName ?? '—'} />
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
<Field label="Quy trình">
{plan.workflowCode ? (
<span>
<span className="font-mono">{plan.workflowCode}</span>
{plan.workflowVersion != null && <span className="font-mono"> v{plan.workflowVersion}</span>}
{plan.workflowName ? ` · ${plan.workflowName}` : ''}
</span>
) : (
<span className="text-slate-300"> Chưa chọn </span>
)}
{/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng.
Dòng chưa gán KHÔNG bịa tên — đếm riêng để người soạn biết còn nợ mấy dòng,
vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */}
<Field label="Hạng mục" full>
{(() => {
const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[]
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
if (names.length === 0 && unassigned === 0) return DASH
return (
<span className="flex flex-wrap items-center gap-1">
{names.map(n => (
<span key={n} className="inline-flex items-center rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700">
{n}
</span>
))}
{unassigned > 0 && (
<span className="inline-flex items-center rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
còn {unassigned} dòng chưa gán
</span>
)}
</span>
)
})()}
</Field>
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
<Field label="Hạn xử lý (tham khảo)" value={plan.slaDeadline ? formatDateTime(plan.slaDeadline) : '—'} />
</div>
{editHeader ? (
@ -468,49 +505,9 @@ export function KhkkDetailContent({
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
<>
{/* [S175 · O3 — ANH CHỐT] TỔNG đặt PHÍA TRÊN bảng, đúng 2 con số:
① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC);
② hiện 2 TỔNG RIÊNG cạnh nhau — Σ giá ĐỀ XUẤT ⟂ Σ giá DUYỆT.
🔴 KHÔNG cộng gộp 2 cột thành một số "hiện hành": đề-xuất và duyệt là hai
con số có ý nghĩa pháp-lý khác nhau (một cái chưa ai chốt, một cái đã qua
choke-point `ApplyApprovedValuesOnFinalize`); trộn lại là bịa ra một số
không tồn tại trong hồ-sơ nào.
🔸 Dòng chưa có giá duyệt ⇒ `null`, `moneyOrDash` in em-dash mờ; tổng duyệt
chỉ cộng phần ĐÃ có, và nói rõ còn bao nhiêu dòng chưa chốt. */}
{(() => {
const sumProposed = plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0)
const approvedLines = plan.lines.filter(l => l.approvedAmount != null)
const sumApproved = approvedLines.reduce((s, l) => s + (l.approvedAmount ?? 0), 0)
const pending = plan.lines.length - approvedLines.length
return (
<div className="mb-3 flex flex-wrap items-stretch gap-2">
<div className="min-w-[13rem] flex-1 rounded-md border border-slate-200 bg-white px-3 py-2">
<div className="label-eyebrow">Tổng giá đ xuất</div>
<div className="mt-0.5 text-base font-semibold tabular-nums text-brand-800">
{formatVnd(sumProposed)}
</div>
<div className="text-[10px] text-slate-500">{plan.lines.length} dòng</div>
</div>
<div className={cn(
'min-w-[13rem] flex-1 rounded-md border px-3 py-2',
pending === 0 ? 'border-emerald-300 bg-emerald-50' : 'border-slate-200 bg-white',
)}>
<div className="label-eyebrow">Tổng giá duyệt</div>
<div className={cn(
'mt-0.5 text-base font-semibold tabular-nums',
pending === 0 ? 'text-emerald-700' : 'text-slate-400',
)}>
{approvedLines.length === 0 ? '—' : formatVnd(sumApproved)}
</div>
<div className="text-[10px] text-slate-500">
{pending === 0
? 'đã chốt đủ mọi dòng'
: `còn ${pending}/${plan.lines.length} dòng chưa chốt giá`}
</div>
</div>
</div>
)
})()}
{/* 🧊 [S175] 2 ô TỔNG (Σ đề xuất ⟂ Σ duyệt) đã GỠ — anh xem trên màn rồi chốt "Bỏ đi".
Trước đó chính anh chọn "hiện 2 tổng riêng" khi em hỏi; nhìn thật thì thừa. Giữ 1 dòng
vết để lần sau khỏi ai dựng lại tưởng là thiếu. */}
{/* [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn
tự-do trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không
thẳng hàng giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG
@ -729,11 +726,9 @@ export function KhkkDetailContent({
)}
</>
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu SNAPSHOT tổng báo giá đưc chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu Nháp/Trả lại mọi dòng phải gán xong mới trình duyệt đưc.
{' '}🔴 Dòng THÊM TAY không giá tham chiếu (hiện &ldquo;&rdquo;) số tham chiếu của cả NCC, đã nằm dòng gốc.
</div>
{/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
Nội dung không mất: ràng-buộc "phải gán hạng mục mới trình duyệt được" vẫn do
submit-guard BE chặn thật, và ô Hạng mục ở mục 1 đã đếm "còn N dòng chưa gán". */}
</Section>
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */}
@ -752,22 +747,22 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)"
count={mergedRows.length}
actions={
readOnly ? undefined : (
<div className="flex items-center gap-2">
{isDraft && (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
</Button>
)}
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
<Upload className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
</label>
</div>
readOnly || !isDraft ? undefined : (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
</Button>
)
}
>
{/* [S175 — anh chốt] Chỗ tải file bê ĐÚNG khuôn Duyệt NCC (`PeDetailTabs.tsx:3527-3531`
+ `:3601-3608`): dòng chữ mô-tả ở TRÊN, nút viền ĐỨT nền brand-nhạt ở DƯỚI danh
sách — thay cho nút đặc nằm ở góc header. Nút viền đứt đọc ra là "chỗ thả thêm
vào", nút đặc ở header đọc ra là "hành động của cả khối"; đây là cái thứ nhất. */}
{!readOnly && (
<p className="mb-2 text-[12px] text-slate-500">
File căn cứ hồ / shopdrawing của kế hoạch. Gắn vào 1 căn cứ cụ thể bằng nút dòng căn cứ đó.
</p>
)}
{mergedRows.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa căn cứ hồ hay file đính kèm nào.</div>
) : (
@ -854,6 +849,27 @@ export function KhkkDetailContent({
))}
</div>
)}
{/* Nút tải lên — viền ĐỨT, đặt DƯỚI danh sách, y khuôn PE `:3601-3608`.
`accept` bê nguyên danh-sách của PE: chặn ngay ở hộp chọn file đỡ được vòng
upload-rồi-mới-biết-sai-định-dạng. */}
{!readOnly && (
<label className={cn(
'mt-2 inline-flex cursor-pointer items-center gap-1.5 rounded border border-dashed border-brand-300',
'bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700',
'hover:border-brand-500 hover:bg-brand-50',
upload.isPending && 'pointer-events-none opacity-50',
)}>
<Upload className="h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải…' : '+ Tải lên hồ sơ'}
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => onPickFiles(e)}
/>
</label>
)}
{previewAtt && (
<AttachmentPreviewDialog
open