[CLAUDE] FE-User: K5 SP-002 cay pipeline GD2 mo 8 sub-folder nhom (FE-only x2 app, 0-mig)
Gate PASS 0-blocker 6/6 (sub-reviewer-gate-k5.md): 8 ngan nhom tu KHKK_APPROVAL_GROUPS + khkkGroupMenuLabel (single-source K4a), phieu vao ngan theo approvalGroup + fallback "(chua phan nhom)" — bat-bien Σ bao dam bang CAU TRUC (1 mang nguon / 1 phep phan hoach / 1 cho dem); HIEN DU 8 ke ca rong; route ngan khop URL K4b /khkk/list?group=n; GD3/GD4 regression chung-duoc-la-khong (union pe.contractId giu, cho-cam lines[].contractId danh dau — no K7 Line.ContractId ghi-luon). SHA-pair x2 byte-identical (diff rong). tsc -b x2 = 0 (gate tu chay) + npm x2 PASS. Treo khong chan (gate MINOR 1-6): chu "SAP" tren ngan rong (UX owner) · a11y button- trong-summary (khuon moi) · FE 0 ha tang test (console.warn dev-only) · toKhkkLeaf x2 lan/phieu. UAT ngay dau: prod 0 phieu KHKK => 8 ngan "SAP" rong o moi goi = DUNG SPEC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -55,9 +55,30 @@ export type PipelineLeaf = {
|
||||
onOpen?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* [K5 S166] Ngăn con TRONG một folder giai đoạn (GĐ2 = 8 nhóm duyệt KHKK).
|
||||
* 🔴 `leaves` ở đây là LEAVES THẬT (chính các phiếu con), KHÔNG phải một đường
|
||||
* `content` riêng — nhờ vậy tổng số phiếu của các ngăn luôn bằng số phiếu của
|
||||
* folder cha theo CẤU TRÚC, không phải nhờ ai đó nhớ cộng cho khớp (vá-9).
|
||||
*/
|
||||
export type PipelineGroup = {
|
||||
key: string
|
||||
label: string
|
||||
leaves: PipelineLeaf[]
|
||||
/** Bấm vào TÊN ngăn ⇒ mở trang danh sách đã lọc sẵn ngăn đó. */
|
||||
onOpenAll?: () => void
|
||||
}
|
||||
|
||||
export type PipelineStage = {
|
||||
n: PipelineStageNo
|
||||
/** Danh sách PHẲNG, là nguồn duy nhất để đếm. `groups` chỉ chia lại chính nó. */
|
||||
leaves: PipelineLeaf[]
|
||||
/**
|
||||
* Có mặt ⇒ thân folder vẽ theo NGĂN thay vì danh sách phẳng. Ngăn RỖNG vẫn
|
||||
* hiện (mirror tiền lệ folder giai đoạn chưa có gì: chip "Sắp") — người dùng
|
||||
* phải thấy đủ khung 8 nhóm để biết cái nào trống, chứ không phải đoán.
|
||||
*/
|
||||
groups?: PipelineGroup[]
|
||||
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
|
||||
locked?: boolean
|
||||
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
|
||||
@ -85,6 +106,106 @@ function SoonChip() {
|
||||
)
|
||||
}
|
||||
|
||||
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
||||
|
||||
/** Danh sách leaf phẳng — dùng chung cho thân folder LẪN thân từng ngăn (K5). */
|
||||
function LeafList({ leaves, style }: { leaves: PipelineLeaf[]; style: StageStyle }) {
|
||||
return (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{leaves.map(leaf => (
|
||||
<li key={leaf.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaf.onOpen}
|
||||
disabled={!leaf.onOpen}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
||||
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
||||
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
||||
{leaf.primary}
|
||||
</span>
|
||||
{leaf.badgeLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
||||
)}
|
||||
>
|
||||
{leaf.badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [K5 S166] Một ngăn trong folder giai đoạn (GĐ2 = 1 nhóm duyệt).
|
||||
* 🔴 Hai thao tác TÁCH BẠCH trên cùng một dòng: bấm vùng dòng = mở/đóng ngăn
|
||||
* (hành vi mặc định của <summary>), bấm ĐÚNG TÊN ngăn = sang trang danh sách đã
|
||||
* lọc sẵn nhóm đó. Nút tên phải chặn nổi bọt, nếu không mỗi lần đi trang lại
|
||||
* kèm một lần đóng/mở ngăn nhấp nháy phía sau.
|
||||
* Mặc định ĐÓNG: 8 ngăn mở sẵn là một bức tường chữ, mà cây này nằm ở cột trái.
|
||||
*/
|
||||
function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle }) {
|
||||
const count = group.leaves.length
|
||||
const empty = count === 0
|
||||
return (
|
||||
<details className="group/grp">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60 [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/grp:rotate-90 motion-reduce:transition-none"
|
||||
/>
|
||||
{group.onOpenAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
group.onOpenAll?.()
|
||||
}}
|
||||
title={`Mở danh sách: ${group.label}`}
|
||||
// Ngăn rỗng vẫn bấm sang trang được (để lập phiếu mới cho nhóm đó)
|
||||
// ⇒ vẫn là chữ đang hoạt động: slate-500 giữ đủ tương phản AA.
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate rounded text-left text-[11px] font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/60',
|
||||
empty ? 'text-slate-500' : style.head,
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn('min-w-0 flex-1 truncate text-[11px] font-medium', empty ? 'text-slate-500' : style.head)}>
|
||||
{group.label}
|
||||
</span>
|
||||
)}
|
||||
{count > 0 ? (
|
||||
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||
) : (
|
||||
<SoonChip />
|
||||
)}
|
||||
</summary>
|
||||
<div className="ml-3 border-l border-slate-200">
|
||||
{empty ? (
|
||||
<div className="px-3 py-1.5 text-[11px] italic text-slate-400">Chưa có phiếu ở nhóm này.</div>
|
||||
) : (
|
||||
<LeafList leaves={group.leaves} style={style} />
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) {
|
||||
// Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được.
|
||||
const [open, setOpen] = useState(stage.n === currentStage)
|
||||
@ -163,43 +284,19 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
||||
</div>
|
||||
) : stage.content !== undefined ? (
|
||||
stage.content
|
||||
) : stage.groups !== undefined ? (
|
||||
// [K5 S166] Có ngăn ⇒ vẽ theo ngăn. Ngăn rỗng KHÔNG bị lọc bỏ ở đây:
|
||||
// việc "hiện đủ 8 nhóm hay không" do bên dựng quyết (xem builder), chỗ
|
||||
// này chỉ vẽ đúng thứ nhận được.
|
||||
<div>
|
||||
{stage.groups.map(g => (
|
||||
<GroupFolder key={g.key} group={g} style={style} />
|
||||
))}
|
||||
</div>
|
||||
) : stage.leaves.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{stage.leaves.map(leaf => (
|
||||
<li key={leaf.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaf.onOpen}
|
||||
disabled={!leaf.onOpen}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
||||
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
||||
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
||||
{leaf.primary}
|
||||
</span>
|
||||
{leaf.badgeLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
||||
)}
|
||||
>
|
||||
{leaf.badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<LeafList leaves={stage.leaves} style={style} />
|
||||
)}
|
||||
{stage.truncated && !stage.failed && !stage.loading && (
|
||||
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||
|
||||
Reference in New Issue
Block a user