[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
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:
@ -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}` : ''}
|
||||
{/* 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 className="text-slate-300">— Chưa chọ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 có 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 là 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 có giá tham chiếu (hiện “—”) — số tham chiếu là 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 && (
|
||||
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>
|
||||
)}
|
||||
<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ồ sơ / 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ó căn cứ hồ sơ 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
|
||||
|
||||
@ -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
|
||||
// "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 { 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 { Select } from '@/components/ui/Select'
|
||||
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>
|
||||
))}
|
||||
</Select>
|
||||
{flatMode && !deletedView && canPe && (
|
||||
<p className="text-[10px] italic text-slate-500">
|
||||
Đ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).
|
||||
</p>
|
||||
)}
|
||||
{/* 🧊 [S175] Dòng "Đang lọc — danh sách lấy thẳng từ máy chủ…" ĐÃ GỠ (anh: "Bỏ mấy dòng
|
||||
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
|
||||
phiếu hay lấy thẳng máy chủ — họ chỉ cần thấy đúng phiếu. */}
|
||||
{!canPe && (
|
||||
<p className="text-[10px] italic text-slate-500">
|
||||
Bạn chưa có 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ó
|
||||
`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. */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden>
|
||||
{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>
|
||||
|
||||
{/* 🧊 [S175] Dải "ĐẦU CỘT" (thêm ở đợt B để chữa "cột cụt đầu") ĐÃ GỠ — anh khoanh đỏ
|
||||
cùng dòng "Đang lọc" và bảo bỏ. Tiêu-đề cột đã có ở header trang ngay trên. */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!flatMode && (
|
||||
<PipelineTreePanel
|
||||
|
||||
@ -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
|
||||
// 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 { 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 { api } from '@/lib/api'
|
||||
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ũ). */
|
||||
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()
|
||||
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
|
||||
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ó,
|
||||
`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. */}
|
||||
{[...byProject.entries()].map(([projectName, byWorkItem]) => (
|
||||
{[...byProject.entries()].map(([projectName, byWorkItem]) => {
|
||||
const pCollapsed = collapsed.has(projectName)
|
||||
return (
|
||||
<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 />
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
|
||||
{projectName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
|
||||
</div>
|
||||
{[...byWorkItem.entries()].map(([workItemName, items]) => (
|
||||
</button>
|
||||
{!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
|
||||
const wKey = `${projectName} | ||||