[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"> <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="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> <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 && ( {plan.approvalGroup != null && (
<span <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" 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 ) : 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"> <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="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="Dự án" value={plan.projectName ?? '—'} />
<Field label="Phòng ban" value={plan.departmentName ?? '—'} /> {/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng.
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} /> 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,
<Field label="Quy trình"> vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */}
{plan.workflowCode ? ( <Field label="Hạng mục" full>
<span> {(() => {
<span className="font-mono">{plan.workflowCode}</span> const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[]
{plan.workflowVersion != null && <span className="font-mono"> v{plan.workflowVersion}</span>} const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
{plan.workflowName ? ` · ${plan.workflowName}` : ''} if (names.length === 0 && unassigned === 0) return DASH
</span> return (
) : ( <span className="flex flex-wrap items-center gap-1">
<span className="text-slate-300"> Chưa chọn </span> {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>
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} /> <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> </div>
{editHeader ? ( {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> <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ố: {/* 🧊 [S175] 2 ô TỔNG (Σ đề xuất ⟂ Σ duyệt) đã GỠ — anh xem trên màn rồi chốt "Bỏ đi".
① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC); 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
② hiện 2 TỔNG RIÊNG cạnh nhau — Σ giá ĐỀ XUẤT ⟂ Σ giá DUYỆT. vết để lần sau khỏi ai dựng lại tưởng là thiếu. */}
🔴 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 {/* [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 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 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"> {/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
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. 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
{' '}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. 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". */}
{' '}🔴 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>
</Section> </Section>
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */} {/* ─────── 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)" chipFg="var(--color-teal-700)"
count={mergedRows.length} count={mergedRows.length}
actions={ actions={
readOnly ? undefined : ( readOnly || !isDraft ? undefined : (
<div className="flex items-center gap-2"> <Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
{isDraft && ( <Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}> </Button>
<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>
) )
} }
> >
{/* [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 ? ( {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> <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> </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 && ( {previewAtt && (
<AttachmentPreviewDialog <AttachmentPreviewDialog
open open

View File

@ -23,7 +23,7 @@
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt. // "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -281,11 +281,9 @@ export function KhkkListPanel({
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option> <option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))} ))}
</Select> </Select>
{flatMode && !deletedView && canPe && ( {/* 🧊 [S175] Dòng "Đang lọc — danh sách lấy thẳng từ máy chủ…" ĐÃ GỠ (anh: "Bỏ mấy dòng
<p className="text-[10px] italic text-slate-500"> này đi"). Đó là chú-thích về CƠ-CHẾ bên trong, người dùng không cần biết cây tải 200
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây). phiếu hay lấy thẳng máy chủ — họ chỉ cần thấy đúng phiếu. */}
</p>
)}
{!canPe && ( {!canPe && (
<p className="text-[10px] italic text-slate-500"> <p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh
@ -298,20 +296,8 @@ export function KhkkListPanel({
không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có
`Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn `Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn
`Tier` để 2 màn KHKK cùng một hình. */} `Tier` để 2 màn KHKK cùng một hình. */}
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2"> {/* 🧊 [S175] Dải "ĐẦU CỘT" (thêm ở đợt B để chữa "cột cụt đầu") ĐÃ GỠ — anh khoanh đỏ
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden> cùng dòng "Đang lọc" và bảo bỏ. Tiêu-đề cột đã có ở header trang ngay trên. */}
{flatMode ? <FileCheck className="h-3 w-3" /> : <ListTree className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{deletedView ? 'Phiếu đã xóa' : flatMode ? 'Danh sách kế hoạch' : 'Cây toàn trình'}
</span>
{flatMode && !list.isLoading && !list.isError && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-600">
{total}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel

View File

@ -24,7 +24,8 @@
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key // con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì". // của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react' import { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { useState } from 'react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
@ -92,6 +93,10 @@ export function KhkkWorkListPanel({
* không vẽ bút chì (cột giữ đúng hình cũ). */ * không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void onEditPlan?: (planId: string) => void
}) { }) {
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
// này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const { can } = usePermission() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
const canKhkk = can('KeHoachKyKet', 'Read') const canKhkk = can('KeHoachKyKet', 'Read')
@ -193,23 +198,52 @@ export function KhkkWorkListPanel({
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó, {/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì `z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */} header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
{[...byProject.entries()].map(([projectName, byWorkItem]) => ( {[...byProject.entries()].map(([projectName, byWorkItem]) => {
const pCollapsed = collapsed.has(projectName)
return (
<div key={projectName}> <div key={projectName}>
<div className="sticky top-0 z-20 flex items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 backdrop-blur"> {/* [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Header CẤP 1 nay là
NÚT gập/mở. Mặc-định MỞ: gập sẵn thì mở panel ra thấy toàn thư-mục đóng,
người dùng tưởng chưa có việc nào. */}
<button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(projectName)) next.delete(projectName); else next.add(projectName)
return next
})}
aria-expanded={!pCollapsed}
className="sticky top-0 z-20 flex w-full items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 text-left backdrop-blur transition hover:bg-slate-100"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-400 transition-transform', !pCollapsed && 'rotate-90')} aria-hidden />
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden /> <FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600"> <span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
{projectName} {projectName}
</span> </span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span> <span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
</div> </button>
{[...byWorkItem.entries()].map(([workItemName, items]) => ( {!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
const wKey = `${projectName}${workItemName}`
const wCollapsed = collapsed.has(wKey)
return (
<div key={workItemName}> <div key={workItemName}>
<div className="sticky top-[1.375rem] z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-6 pr-3 backdrop-blur"> <button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(wKey)) next.delete(wKey); else next.add(wKey)
return next
})}
aria-expanded={!wCollapsed}
className="sticky top-[1.375rem] z-10 flex w-full items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-5 pr-3 text-left backdrop-blur transition hover:bg-slate-50"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-300 transition-transform', !wCollapsed && 'rotate-90')} aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500"> <span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500">
{workItemName} {workItemName}
</span> </span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span> <span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span>
</div> </button>
{!wCollapsed && (
<ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1"> <ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1">
{items.map((pe) => ( {items.map((pe) => (
<li key={pe.id}> <li key={pe.id}>
@ -239,10 +273,13 @@ export function KhkkWorkListPanel({
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
))} )
})}
</div> </div>
))} )
})}
</Tier> </Tier>
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />

View File

@ -41,7 +41,6 @@ import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { import {
KhkkPhase, KhkkPhase,
khkkGroupMenuLabel,
type KhkkDetailDto, type KhkkDetailDto,
type KhkkPhaseValue, type KhkkPhaseValue,
} from '@/types/khkk' } from '@/types/khkk'
@ -159,9 +158,9 @@ export function KhkkListPage() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" /> <FileCheck className="h-5 w-5 text-slate-500" />
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1> <h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 truncate rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600"> {/* 🧊 [S175] Chip "Giai đoạn 2 · <tên nhóm dài>" ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''} Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
</span> đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
</div> </div>
{!deletedView && ( {!deletedView && (
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}> <Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>

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"> <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="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> <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 && ( {plan.approvalGroup != null && (
<span <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" 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 ) : 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"> <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="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="Dự án" value={plan.projectName ?? '—'} />
<Field label="Phòng ban" value={plan.departmentName ?? '—'} /> {/* 1 phiếu nay được N hạng mục (S175) ⇒ liệt kê DUY-NHẤT, giữ thứ tự dòng.
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} /> 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,
<Field label="Quy trình"> vì submit-guard sẽ chặn trình duyệt khi còn dòng chưa gán. */}
{plan.workflowCode ? ( <Field label="Hạng mục" full>
<span> {(() => {
<span className="font-mono">{plan.workflowCode}</span> const names = [...new Set(plan.lines.map(l => l.tenHangMuc).filter(Boolean))] as string[]
{plan.workflowVersion != null && <span className="font-mono"> v{plan.workflowVersion}</span>} const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
{plan.workflowName ? ` · ${plan.workflowName}` : ''} if (names.length === 0 && unassigned === 0) return DASH
</span> return (
) : ( <span className="flex flex-wrap items-center gap-1">
<span className="text-slate-300"> Chưa chọn </span> {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>
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} /> <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> </div>
{editHeader ? ( {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> <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ố: {/* 🧊 [S175] 2 ô TỔNG (Σ đề xuất ⟂ Σ duyệt) đã GỠ — anh xem trên màn rồi chốt "Bỏ đi".
① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC); 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
② hiện 2 TỔNG RIÊNG cạnh nhau — Σ giá ĐỀ XUẤT ⟂ Σ giá DUYỆT. vết để lần sau khỏi ai dựng lại tưởng là thiếu. */}
🔴 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 {/* [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 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 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"> {/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
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. 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
{' '}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. 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". */}
{' '}🔴 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>
</Section> </Section>
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */} {/* ─────── 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)" chipFg="var(--color-teal-700)"
count={mergedRows.length} count={mergedRows.length}
actions={ actions={
readOnly ? undefined : ( readOnly || !isDraft ? undefined : (
<div className="flex items-center gap-2"> <Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
{isDraft && ( <Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}> </Button>
<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>
) )
} }
> >
{/* [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 ? ( {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> <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> </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 && ( {previewAtt && (
<AttachmentPreviewDialog <AttachmentPreviewDialog
open open

View File

@ -23,7 +23,7 @@
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt. // "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -281,11 +281,9 @@ export function KhkkListPanel({
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option> <option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))} ))}
</Select> </Select>
{flatMode && !deletedView && canPe && ( {/* 🧊 [S175] Dòng "Đang lọc — danh sách lấy thẳng từ máy chủ…" ĐÃ GỠ (anh: "Bỏ mấy dòng
<p className="text-[10px] italic text-slate-500"> này đi"). Đó là chú-thích về CƠ-CHẾ bên trong, người dùng không cần biết cây tải 200
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây). phiếu hay lấy thẳng máy chủ — họ chỉ cần thấy đúng phiếu. */}
</p>
)}
{!canPe && ( {!canPe && (
<p className="text-[10px] italic text-slate-500"> <p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh
@ -298,20 +296,8 @@ export function KhkkListPanel({
không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có
`Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn `Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn
`Tier` để 2 màn KHKK cùng một hình. */} `Tier` để 2 màn KHKK cùng một hình. */}
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2"> {/* 🧊 [S175] Dải "ĐẦU CỘT" (thêm ở đợt B để chữa "cột cụt đầu") ĐÃ GỠ — anh khoanh đỏ
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden> cùng dòng "Đang lọc" và bảo bỏ. Tiêu-đề cột đã có ở header trang ngay trên. */}
{flatMode ? <FileCheck className="h-3 w-3" /> : <ListTree className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{deletedView ? 'Phiếu đã xóa' : flatMode ? 'Danh sách kế hoạch' : 'Cây toàn trình'}
</span>
{flatMode && !list.isLoading && !list.isError && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-600">
{total}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel

View File

@ -24,7 +24,8 @@
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key // con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì". // của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react' import { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { useState } from 'react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
@ -92,6 +93,10 @@ export function KhkkWorkListPanel({
* không vẽ bút chì (cột giữ đúng hình cũ). */ * không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void onEditPlan?: (planId: string) => void
}) { }) {
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
// này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const { can } = usePermission() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
const canKhkk = can('KeHoachKyKet', 'Read') const canKhkk = can('KeHoachKyKet', 'Read')
@ -193,23 +198,52 @@ export function KhkkWorkListPanel({
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó, {/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì `z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */} header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
{[...byProject.entries()].map(([projectName, byWorkItem]) => ( {[...byProject.entries()].map(([projectName, byWorkItem]) => {
const pCollapsed = collapsed.has(projectName)
return (
<div key={projectName}> <div key={projectName}>
<div className="sticky top-0 z-20 flex items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 backdrop-blur"> {/* [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Header CẤP 1 nay là
NÚT gập/mở. Mặc-định MỞ: gập sẵn thì mở panel ra thấy toàn thư-mục đóng,
người dùng tưởng chưa có việc nào. */}
<button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(projectName)) next.delete(projectName); else next.add(projectName)
return next
})}
aria-expanded={!pCollapsed}
className="sticky top-0 z-20 flex w-full items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 text-left backdrop-blur transition hover:bg-slate-100"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-400 transition-transform', !pCollapsed && 'rotate-90')} aria-hidden />
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden /> <FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600"> <span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
{projectName} {projectName}
</span> </span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span> <span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
</div> </button>
{[...byWorkItem.entries()].map(([workItemName, items]) => ( {!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
const wKey = `${projectName}${workItemName}`
const wCollapsed = collapsed.has(wKey)
return (
<div key={workItemName}> <div key={workItemName}>
<div className="sticky top-[1.375rem] z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-6 pr-3 backdrop-blur"> <button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(wKey)) next.delete(wKey); else next.add(wKey)
return next
})}
aria-expanded={!wCollapsed}
className="sticky top-[1.375rem] z-10 flex w-full items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-5 pr-3 text-left backdrop-blur transition hover:bg-slate-50"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-300 transition-transform', !wCollapsed && 'rotate-90')} aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500"> <span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500">
{workItemName} {workItemName}
</span> </span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span> <span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span>
</div> </button>
{!wCollapsed && (
<ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1"> <ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1">
{items.map((pe) => ( {items.map((pe) => (
<li key={pe.id}> <li key={pe.id}>
@ -239,10 +273,13 @@ export function KhkkWorkListPanel({
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
))} )
})}
</div> </div>
))} )
})}
</Tier> </Tier>
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />

View File

@ -41,7 +41,6 @@ import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { import {
KhkkPhase, KhkkPhase,
khkkGroupMenuLabel,
type KhkkDetailDto, type KhkkDetailDto,
type KhkkPhaseValue, type KhkkPhaseValue,
} from '@/types/khkk' } from '@/types/khkk'
@ -159,9 +158,9 @@ export function KhkkListPage() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" /> <FileCheck className="h-5 w-5 text-slate-500" />
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1> <h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 truncate rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600"> {/* 🧊 [S175] Chip "Giai đoạn 2 · <tên nhóm dài>" ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''} Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
</span> đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
</div> </div>
{!deletedView && ( {!deletedView && (
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}> <Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>