[CLAUDE] Contract: UAT S174 dot-D + C3 — O6/O4/O7/O8 + 2 tong toan phieu (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s

Owner S175 chot cach doc "sum tong" (gate TIEN): (1) truc = CHI TONG TOAN PHIEU, KHONG co
dong tong per-NCC; (2) hien 2 TONG RIENG canh nhau — Sigma gia DE XUAT ⟂ Sigma gia DUYET,
dat PHIA TREN bang. Mirror 9 CAP file sha256 khop 9/9; build XANH ca 2 app, 0 error TS.

=== O6 — bam card PE tren cay GIU NGUYEN man KHKK ===
+callback `onOpenPe` (usePipelineStages) mirror DOI XUNG `onOpenKhkk` cua leaf GD2.
KHONG truyen => giu `navigate('/purchase-evaluations?id=..&expand=1')` => 5 host cay con lai
0 doi hanh vi (da verify: chi KhkkListPage truyen).
Host: `?pe=<id>` render `PeDetailTabs readOnly mode="workspace"` ngay panel 2 — DUNG LAI
renderer that cua man PE thay vi dung ban rut gon thu hai (2 ban = 2 su that).
🔴 `pe` va `id` LOAI TRU NHAU (2 setter cung xoa key kia): panel 2 chi ve duoc mot thu, de
ca hai thi lan chon sau khong xoa duoc lan truoc.
`<lg` roi ve duong cu (khong co panel 2 thi xem-tai-cho la bat kha).
Panel 3 doi chu khi dang xem PE — cau cu "se hien khi chon phieu" la SAI (phieu DA chon roi).

=== O4 — shopdrawing / can cu ho so cho dinh kem file (FE-only) ===
🔴 0 BE MOI — rail da du: controller `[FromForm] Guid? dossierItemId` (:181) + DTO
`KhkkAttachmentDto.dossierItemId` (:241) co tu Mig 69. FE truoc day don gian la KHONG BAO GIO
gui truong do len => moi file roi ve muc PHIEU. Nay upload gui kem dossierItemId.
Chia 2 ro: file gan can cu hien ngay DUOI dong can cu do; Section 4 chi con file muc phieu —
khong chia thi file hien o CA HAI cho, nguoi dung dem 2 lan.
Dong-con file hien CA KHI read-only (nguoi duyet can xem shopdrawing, chi khong them/xoa).

=== O7 — nut edit tren card KHKK (man Thao tac) ===
But chi khuon `PeListPanel:208-232`. LUON hien roi MO DI khi khong sua duoc: nut chi-hien-khi-
hover thi tren may cam-ung khong ai tim ra; an han khi phieu khoa thi nguoi dung tuong chuc
nang khong ton tai thay vi hieu la phieu da di duyet.
🔴 Nut NGOAI the <button> cua card — long button trong button la HTML khong hop le.
Host: but chi => mo che-do SUA; than card => chi xem nhu cu. Guard readOnly Y HET
`KhkkListPage:147-150` (cung mot cau hoi "ai duoc sua" phai cho cung mot cau tra loi o moi man).
Da verify khong phai co-hoc: `KhkkDetailContent:161` `isDraft = draftPhase && !readOnly`.

=== O8 — cay 2 cap Du an > Hang muc ===
🔴 0 ENDPOINT MOI: `workItemName` DA co san trong `KhkkPeCandidateDto:423` => cap 2 la gom lai
tren du-lieu dang co. Truoc day 1 cap theo du an nen mot du an nhieu goi thau do ra danh sach
phang dai, khong thay ranh gioi hang muc.
🔴 2 muc `z` khac nhau (z-20 cha / z-10 con): cung z thi header hang muc truot DE len header
du an khi cuon, mat cap cha.

=== C3 — Field: value ve slate-900, label giu brand-600 ===
Truoc day MOI value deu to brand => mau thuong-hieu mat het suc nang: ca trang deu la mau nhan
thi khong con gi la nhan, va khong chua duoc mau cho thu THAT SU can noi (NCC trung thau,
canh bao vuot ngan sach). PE giu slate cho value dung vi ly do nay.

=== O3 — MOI LAND VE (2). VE (1) CHUA LAM — xem CON NO ===
🔴 KHONG cong gop 2 cot thanh mot so "hien hanh": de-xuat va duyet la 2 so co y nghia phap-ly
khac nhau (mot cai chua ai chot, mot cai da qua choke-point `ApplyApprovedValuesOnFinalize`);
tron lai la bia ra mot so khong ton tai trong ho-so nao.
Tong duyet chi cong phan DA co, va noi ro con bao nhieu dong chua chot.

=== 🔴 CON NO — O3 ve (1) "hang muc cho chon NHIEU" KHONG PHAI FE-ONLY ===
Spec ghi O3 = "N-ROW 0-migration". "0-migration" DUNG (unique 3 cot PlanId+SupplierId+
CatalogEntryId co tu Mig 71) NHUNG KHONG co nghia la 0 BE. Do that:
  · KHONG co endpoint TAO line. Chi co `PUT /{id}/lines/{lineId}` = gan catalogEntryId cho
    dong DA CO (Controller:121). Danh sach route day du: 0 route POST/DELETE line.
  · Line duoc auto-sinh 1-dong-moi-NCC-trung-thau luc TAO PHIEU (`Features.cs:508-527`),
    tien moi = tong bao gia DUOC CHON cua NCC do.
=> Muon 1 NCC co N hang muc phai TACH tien cua NCC do ra N phan — va KHONG CO quy tac nao
   trong he thong noi chia the nao. Do la cau hoi TIEN, chua ai tra loi.
🔸 Tin tot: BE cau HD da nhan N dong san (`CreateContractFromSigningPlanFeatures:101-115`
   nhan `LineIds` list, chi rao "moi HD 1 NCC"). Cai hep la FE dialog dung radio single-select
   (`KhkkDetailPage:310`) — do la UI, doi duoc.

=== CON NO KHAC ===
🔴 CHUA soi mat tren prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3) — moi ket luan la
DOC MA, khong phai da nhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 19:59:04 +07:00
parent 43e1bd7bc2
commit a0ccac3982
14 changed files with 758 additions and 142 deletions

View File

@ -18,7 +18,7 @@
// Không dựng workspace riêng cho việc này.
// [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback
// V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu.
import { useState, type ReactNode } from 'react'
import { Fragment, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
@ -123,7 +123,11 @@ function Field({
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{/* [S175 · C3] VALUE về `text-slate-900`, LABEL giữ `text-brand-600`. Trước đây MỌI
value đều tô brand ⇒ màu thương-hiệu mất hết sức nặng: khi cả trang đều là màu nhấn
thì không còn gì là nhấn, và không chừa được màu cho thứ THẬT SỰ cần nổi (NCC trúng
thầu, cảnh báo vượt ngân sách). PE giữ slate cho value đúng vì lý do này. */}
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-slate-900', mono && !empty && 'font-mono')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
@ -218,11 +222,16 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)),
})
// [S175 · O4] +`dossierItemId`: gắn file vào ĐÚNG một căn cứ hồ sơ (shopdrawing, mẫu vật
// liệu…) thay vì đổ chung một rổ. 🔴 0 BE MỚI — rail `dossierItemId` đã đủ 6 tầng từ Mig 69
// (`KhkkAttachmentDto:241` ⟵ `POST /attachments` multipart `dossierItemId?`); trước đây FE
// đơn giản là KHÔNG BAO GIỜ gửi trường đó lên, nên mọi file rơi về mức phiếu.
const upload = useMutation({
mutationFn: async (file: File) => {
mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => {
const fd = new FormData()
fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
if (dossierItemId) fd.append('dossierItemId', dossierItemId)
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
@ -253,11 +262,11 @@ export function KhkkDetailContent({
}
}
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>, dossierItemId?: string) {
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* lỗi đã toast ở onError */ }
try { await upload.mutateAsync({ file: f, dossierItemId }) } catch { /* lỗi đã toast ở onError */ }
}
}
@ -268,7 +277,21 @@ export function KhkkDetailContent({
}
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment)
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
// chỉ còn file mức PHIẾU. 🔴 Không chia thì file gắn căn cứ hiện ở CẢ HAI chỗ — người dùng
// đếm 2 lần rồi tưởng đã tải nhầm lên hai bận.
const dossierAttachments = new Map<string, KhkkAttachmentDto[]>()
const planAttachments: KhkkAttachmentDto[] = []
for (const a of plan.attachments) {
if (a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) continue
if (a.dossierItemId) {
const bucket = dossierAttachments.get(a.dossierItemId)
if (bucket) bucket.push(a)
else dossierAttachments.set(a.dossierItemId, [a])
} else {
planAttachments.push(a)
}
}
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
@ -389,10 +412,54 @@ export function KhkkDetailContent({
{plan.lines.length === 0 ? (
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
// [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 (đúng ý đồ của
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ.
<>
{/* [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 · 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
(đúng ý đồ của `overflow-x-auto` bọc ngoài) thay vì bóp chữ. */}
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
@ -467,6 +534,7 @@ export function KhkkDetailContent({
</tbody>
</table>
</div>
</>
)}
<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.
@ -515,8 +583,12 @@ export function KhkkDetailContent({
</tr>
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
const files = dossierAttachments.get(d.id) ?? []
return (
<Fragment key={d.id}>
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
<td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2">
@ -532,6 +604,21 @@ export function KhkkDetailContent({
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
dùng ref thì phải nuôi N ref cho N dòng. */}
<label
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
>
<Upload className="h-3.5 w-3.5" />
<input
type="file"
multiple
className="hidden"
onChange={(e) => onPickFiles(e, d.id)}
/>
</label>
<button
type="button"
onClick={() => setItemForm({
@ -555,7 +642,46 @@ export function KhkkDetailContent({
</td>
)}
</tr>
))}
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
{files.length > 0 && (
<tr className="border-b border-slate-100 last:border-0">
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
<div className="flex flex-wrap gap-1.5 pl-3">
{files.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
>
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
<button
type="button"
onClick={() => download(a)}
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
>
{a.fileName}
</button>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{isDraft && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
title="Xóa file"
className="shrink-0 rounded text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>