[CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC (GD2 het lech kien-truc) + 4 mitigation cuong-hanh

Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang
dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho
list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech
duy nhat trong 4 giai-doan truoc dot nay.

BE (7/7):
- DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName
  / FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append —
  ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay
  workflow da nap, thay vi port 3 subquery cua PE).
- ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo).
- StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5).
- GET {id}/changelogs — policy KeHoachKyKet.Read.
- POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only).
- approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE.
- KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo
  gotcha #82 (class [Authorize] tran + changelogs khong policy).

FE (x2 app, SHA256 byte-identical 8/8):
- components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx;
  KhkkWorkflowPanel doi tu pages/ sang components/.
- Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay
  DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta
  "x/y cap da duyet · N NV tham gia" thay list phang 1 cot.
- Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo
  buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su
  thay doi. Chua chon phieu -> hop dashed y PE (QD owner).

4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH
khong phai chap-nhan VO):
- M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen
  KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc —
  display-layer khoa authz-layer dang mo, mat con lai cua #82.
- M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy
  trang PE/GD3.
- B1 stage2Content di CAP voi stage2Count qua conditional-spread:
  khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8.
- R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao
  "Cay tai 200 phieu moi nhat".

Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app ·
mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) ·
KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang
nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor
thanh useEffect se tai-hien va-5).

Con no, khai thang:
- E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px)
  THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff
  bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang.
- Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other
  (full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao
  new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do
  (git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san".
- 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev
  0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu
  KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe ·
  khoi file tu-an khi rong.
- P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan"
  do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu
  workflow Duyet NCC prod").

Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-04 19:48:38 +07:00
parent f151320780
commit 998ea559f3
21 changed files with 4581 additions and 3179 deletions

View File

@ -0,0 +1,709 @@
// [S171 KHKK 3-panel] PANEL 2 (giữa) — nội-dung phiếu "Kế hoạch ký kết HĐ" (GĐ2).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 Khối ý-kiến cấp duyệt (Section 5) là bản thứ 3 của cùng một hình (PE
// `PeDetailTabs.tsx:705-774` · HĐ `components/contracts/WorkflowHistoryPanel.tsx`).
// Gộp được KHI VÀ CHỈ KHI BE 3 module thống-nhất shape `status` precompute — trước đó
// gộp chỉ đẻ ra `if (module === …)`.
//
// Bố-cục mirror Duyệt NCC: dải 4 giai-đoạn trên cùng, rồi các Section ĐÁNH SỐ nối nhau
// bằng `divide-y`, ý-kiến cấp duyệt nằm DƯỚI CÙNG (Ảnh 3 khoanh đỏ).
// 1. Thông tin kế hoạch 4. File đính kèm
// 2. Giá đề xuất theo NCC 5. Ý kiến cấp duyệt
// 3. Căn cứ hồ sơ (b.8-9)
// 🔸 KHÁC PE ở NỘI DUNG (miễn-trừ P2-3, có lý-do nghiệp-vụ): GĐ2 không có báo-giá / so-sánh
// NCC (đã chốt ở GĐ1) mà có căn-cứ hồ-sơ TVGS. Giống nhau ở KHUNG, không ở chữ.
//
// [P2-7] `readOnly` là prop TỪ HOST — host quyết `phiếu ≠ Nháp user ≠ người soạn`.
// 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus,
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
function formatSize(b: number): string {
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
}
/** Khung Section ĐÁNH SỐ — mirror khuôn Section của màn Duyệt NCC (đánh số + divide-y). */
function Section({
title, icon, accent, head, chipBg, chipFg, count, actions, children,
}: {
title: string
icon: ReactNode
accent: string
head: string
chipBg: string
chipFg: string
count?: number
actions?: ReactNode
children: ReactNode
}) {
return (
<section className="px-5 py-4">
<header className="mb-3 flex items-center gap-2">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
aria-hidden
>
{icon}
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', head)} style={{ ['--accent' as string]: accent }}>
{title}
</h3>
{count != null && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{count}
</span>
)}
{actions && <span className="ml-auto">{actions}</span>}
</header>
{children}
</section>
)
}
function Field({
label, value, mono, full, children,
}: {
label: string
value?: ReactNode
mono?: boolean
full?: boolean
children?: ReactNode
}) {
const empty = value == null || value === ''
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')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
)
}
const EMPTY_ITEM: UpsertKhkkDossierItemInput = {
kind: DossierItemKind.MauVatLieu,
name: '',
status: DossierItemStatus.ChuaNop,
tvgsName: '',
note: '',
}
export function KhkkDetailContent({
plan,
readOnly,
onChanged,
}: {
plan: KhkkDetailDto
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
readOnly: boolean
onChanged?: () => void
}) {
const qc = useQueryClient()
const id = plan.id
const [editHeader, setEditHeader] = useState(false)
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
const phase = plan.phase as KhkkPhaseValue
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
// Sửa được = phiếu còn ở Nháp/Trả lại VÀ host không khoá (readOnly).
const isDraft = draftPhase && !readOnly
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['khkk-detail', id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
onChanged?.()
}
const saveHeader = useMutation({
mutationFn: async () => {
await api.put(`/contract-signing-plans/${id}`, {
ghiChu: ghiChu.trim() || null,
hoSoLink: hoSoLink.trim() || null,
})
},
onSuccess: () => { toast.success('Đã lưu'); setEditHeader(false); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({
queryKey: ['contract-catalog', plan.approvalGroup],
queryFn: async () =>
(await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
params: { approvalGroup: plan.approvalGroup },
})).data,
enabled: isDraft && plan.approvalGroup != null,
})
const assignLine = useMutation({
mutationFn: async (p: { lineId: string; catalogEntryId: string }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }),
onSuccess: () => { toast.success('Đã gán hạng mục'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
const payload = {
...body,
contractSigningPlanId: id,
name: body.name.trim(),
tvgsName: body.tvgsName?.trim() || null,
note: body.note?.trim() || null,
}
// [F-4 S161] POST ép Id=null = LUÔN insert ⇒ "Sửa" phải đi PUT, không thì đẻ bản sao im lặng.
if (body.id) await api.put(`/contract-signing-plans/${id}/dossier-items/${body.id}`, payload)
else await api.post(`/contract-signing-plans/${id}/dossier-items`, payload)
},
onSuccess: () => { toast.success('Đã lưu căn cứ'); setItemForm(null); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteItem = useMutation({
mutationFn: async (itemId: string) => api.delete(`/contract-signing-plans/${id}/dossier-items/${itemId}`),
onSuccess: () => { toast.success('Đã xóa căn cứ'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => { toast.success('Đã tải lên'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteAttachment = useMutation({
mutationFn: async (attId: string) => api.delete(`/contract-signing-plans/${id}/attachments/${attId}`),
onSuccess: () => { toast.success('Đã xóa file'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
async function download(att: KhkkAttachmentDto) {
try {
const res = await api.get(`/contract-signing-plans/${id}/attachments/${att.id}/download`, {
responseType: 'blob',
})
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
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 */ }
}
}
const startEditHeader = () => {
setGhiChu(plan.ghiChu ?? '')
setHoSoLink(plan.hoSoLink ?? '')
setEditHeader(true)
}
// 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)
// ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ──────────────────
const opinions = plan.levelOpinions ?? []
const steps = plan.workflowSteps ?? []
return (
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
{/* Dải 4 giai-đoạn — GĐ2 đang sáng. Cùng component với màn Duyệt NCC (không nhân bản). */}
<PePipelineStrip current={2} />
<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>
{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"
title={KHKK_APPROVAL_GROUP_LABELS[plan.approvalGroup] ?? `Nhóm ${plan.approvalGroup}`}
>
N{plan.approvalGroup}
</span>
)}
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', KHKK_PHASE_BADGE[phase])}>
{KHKK_PHASE_LABELS[phase]}
</span>
</div>
<div className="divide-y divide-slate-200">
{/* ─────────────── 1 ─────────────── */}
<Section
title="1. Thông tin kế hoạch"
icon={<FileCheck className="h-4 w-4" />}
accent="var(--color-brand-500)"
head="text-brand-800"
chipBg="var(--color-brand-50)"
chipFg="var(--color-brand-600)"
actions={
isDraft && !editHeader ? (
<Button variant="outline" size="sm" onClick={startEditHeader}>
<Pencil className="mr-2 h-3.5 w-3.5" /> Sửa
</Button>
) : undefined
}
>
<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>
)}
</Field>
<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 ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
<div>
<Label htmlFor="hoSoLink">Link hồ (NAS)</Label>
<Input id="hoSoLink" value={hoSoLink} onChange={(e) => setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />
</div>
<div>
<Label htmlFor="ghiChu">Ghi chú</Label>
<Textarea id="ghiChu" value={ghiChu} onChange={(e) => setGhiChu(e.target.value)} maxLength={2000} rows={4} />
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>Huỷ</Button>
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
<Save className="mr-2 h-3.5 w-3.5" />
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Link hồ sơ" full>
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300"></span>}
</Field>
<Field label="Ghi chú" full>
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300"></span>}
</Field>
</div>
)}
</Section>
{/* ─────────────── 2 ─────────────── */}
<Section
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`}
icon={<Users className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
actions={
plan.budgetFrozen ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
🔒 Giá đã chốt tại thời điểm duyệt
</span>
) : undefined
}
>
{plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
</tr>
</thead>
<tbody>
{plan.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'}
{l.contractId && (
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
</a>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{isDraft && !l.contractId ? (
<>
<select
value={l.catalogEntryId ?? ''}
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
disabled={assignLine.isPending}
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
không thì dòng đã gán TRÔNG như chưa gán. */}
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
)}
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
{catalogOptions.isError && (
<div className="mt-1 text-[11px] text-red-600">
Không tải đưc danh mục hạng mục {' '}
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>thử lại</button>
</div>
)}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
<div className="mt-1 text-[11px] text-amber-700">
Nhóm duyệt N{plan.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục.
</div>
)}
</>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td>
<td className="px-3 py-2 text-xs text-slate-600">{l.note ?? '—'}</td>
</tr>
))}
</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.
{' '}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.
</div>
</Section>
{/* ─────────────── 3 ─────────────── */}
<Section
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
icon={<ClipboardList className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={plan.dossierItems.length}
actions={
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>
) : undefined
}
>
{plan.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
</tr>
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 text-xs text-slate-600">{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">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{d.tvgsName ?? '—'}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td>
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => setItemForm({
id: d.id, kind: d.kind, supplierId: d.supplierId, name: d.name,
status: d.status, tvgsName: d.tvgsName ?? '', note: d.note ?? '',
})}
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id) }}
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* ─────────────── 4 ─────────────── */}
<Section
title="4. File đính kèm"
icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={planAttachments.length}
actions={
readOnly ? undefined : (
<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={onPickFiles} />
</label>
)
}
>
{planAttachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : (
<ul className="space-y-2">
{planAttachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button>
{!readOnly && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
className="text-xs text-red-600 hover:underline"
>
Xóa
</button>
)}
</div>
</li>
))}
</ul>
)}
</Section>
{/* ─────────────── 5 — Ảnh 3 khoanh đỏ ───────────────
Gom theo BƯỚC (kèm PHÒNG) → lưới 2 cột các ô ý-kiến của từng CẤP, đúng khuôn
Section "Ý kiến cấp duyệt" V2 của Duyệt NCC (`PeDetailTabs.tsx:705-774`):
forEach Bước → forEach Cấp → 1 ô. Ô chưa ký vẫn HIỆN (chỗ trống có tên người) —
vắng ô là người đọc không biết còn thiếu chữ ký của ai. */}
<Section
title="5. Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={opinions.length}
>
{steps.length === 0 ? (
<div className="text-sm text-muted-foreground">
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<div className="space-y-4">
{steps.map((step) => {
const signedCount = step.levels.filter(lv =>
opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length
return (
<div key={step.id} className="rounded-lg border border-slate-200">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2">
<span className="text-xs font-semibold text-slate-800">Bước {step.order} {step.name}</span>
{step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600">
{step.departmentName}
</span>
)}
<span className="ml-auto text-[10px] text-slate-500">
{signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
</span>
</div>
<div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">
{step.levels.length === 0 && (
<p className="text-[11px] italic text-slate-400">Bước này chưa cấu hình cấp duyệt.</p>
)}
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-md border px-2.5 py-2 text-[11px]',
signed ? 'border-emerald-200 bg-emerald-50/40' : 'border-dashed border-slate-200 bg-white',
)}
>
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{lv.name || `Cấp ${lv.order}`}</span>
<span className="text-slate-400">·</span>
<span>{lv.approverFullName ?? '(chưa cấu hình)'}</span>
{signed && (
<>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(signed.signedAt)}</span>
{signed.approverUserId && signed.approverUserId !== signed.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay ({signed.signedByFullName})
</span>
)}
</>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{signed
? (signed.comment?.trim() || '(duyệt — không ý kiến)')
: <span className="italic text-slate-300">Chưa </span>}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)}
</Section>
</div>
{/* Dialog thêm/sửa căn cứ */}
<Dialog open={!!itemForm} onClose={() => setItemForm(null)} title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}>
{itemForm && (
<div className="space-y-3">
<div>
<Label htmlFor="item-kind">Loại căn cứ</Label>
<select
id="item-kind"
value={itemForm.kind}
onChange={(e) => setItemForm({ ...itemForm, kind: Number(e.target.value) as DossierItemKindValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_KIND_LABELS).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-name">Tên căn cứ <span className="text-red-500">*</span></Label>
<Input id="item-name" value={itemForm.name} onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })} maxLength={300} placeholder="vd. Mẫu thép D10 Hoà Phát" />
</div>
<div>
<Label htmlFor="item-status">Trạng thái</Label>
<select
id="item-status"
value={itemForm.status}
onChange={(e) => setItemForm({ ...itemForm, status: Number(e.target.value) as DossierItemStatusValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_STATUS_LABELS).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-tvgs">Tên TVGS xử </Label>
<Input id="item-tvgs" value={itemForm.tvgsName ?? ''} onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })} maxLength={200} />
</div>
<div>
<Label htmlFor="item-note">Ghi chú</Label>
<Textarea id="item-note" value={itemForm.note ?? ''} onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })} maxLength={1000} rows={3} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setItemForm(null)}>Huỷ</Button>
<Button onClick={() => upsertItem.mutate(itemForm)} disabled={upsertItem.isPending || !itemForm.name.trim()}>
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
)}
</Dialog>
</div>
)
}

View File

@ -0,0 +1,388 @@
// [S171 KHKK 3-panel] PANEL 1 của màn "Kế hoạch ký kết HĐ" (GĐ2) — mirror bố-cục
// Duyệt NCC (GĐ1) / Hợp đồng (GĐ3): khối filter trên cùng + thân danh sách cuộn riêng.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 KHỐI Ý-KIẾN / SƠ-ĐỒ DUYỆT là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774`
// · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được khi và chỉ khi BE 3 module
// thống-nhất shape `status` precompute. Trước đó, gộp = đẻ `if (module === …)`.
//
// ══ OWNER CHỐT 2026-08-04 (QĐ-1): panel 1 = CÂY TOÀN TRÌNH `PipelineTreePanel currentStage={2}`.
// Chọn HÌNH ≠ chấp-nhận VỠ ⇒ 4 mitigation dưới đây là BẮT BUỘC, không phải tuỳ chọn:
// M-1 `canPe` (`usePipelineStages.ts:83`) khoá cây bằng 3 key của Duyệt NCC. Người CHỈ có
// quyền KHKK (vd người duyệt nhóm N5) sẽ thấy cây trống trong khi API
// `GET /contract-signing-plans` (policy `KeHoachKyKet.Read`) VẪN cho họ đọc — đúng mặt
// còn lại của gotcha #82. ⇒ `!canPe` FALLBACK sang DANH-SÁCH PHẲNG cùng ô này.
// Cây là lớp LÀM GIÀU, KHÔNG phải điều-kiện để thấy phiếu.
// M-2 Leaf/card KHKK trên cây KHÔNG `navigate('/khkk/:id')` nữa mà gọi `onSelect` của host
// (`usePipelineStages.ts` `onOpenKhkk`) — không thì bấm 1 cái là văng khỏi màn 3-panel.
// B1 Truyền `stage2Content` PHẢI kèm `stage2Count` (`PipelineStageFolders.tsx:95-97`).
// Trade-off khai thẳng: trong cây của trang này, folder GĐ2 hiện CARD PHIẾU thay cho
// 8 ngăn N1-N8; lối vào nhóm giữ ở `Select` nhóm ngay bên trên.
// R-11 Cây chỉ tải 200 phiếu PE mới nhất (`PipelineTreePanel.tsx:22`) và KHÔNG có "trang 2".
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
// (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 { FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
} from '@/types/khkk'
const PAGE_SIZE = 20
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
/**
* [F-2] Chip "⚑ Kết thúc · {Bước}" — khuôn `components/pe/PeFinalizeChip.tsx` (S89/S97),
* dựng lại ở đây thay vì import vì nguồn cờ khác module (KHKK đọc
* `endedByLevelFinalize` + `finalizeStepName`, PE đọc `endsBeforeCeo`).
*/
export function KhkkFinalizeChip({
endedByLevelFinalize,
finalizeStepName,
phase,
}: {
endedByLevelFinalize: boolean
finalizeStepName: string | null
phase?: number
}) {
if (!endedByLevelFinalize) {
if (phase === KhkkPhase.DaDuyet) {
return (
<span
className="inline-flex shrink-0 items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800"
title="Kế hoạch đi HẾT quy trình (không kết thúc sớm ở cấp dưới)"
>
Duyệt hết quy trình
</span>
)
}
return null
}
return (
<span
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình tiếp cấp sau`}
>
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Kết thúc sớm'}
</span>
)
}
/**
* Card 3 hàng — thay cho bảng 8 cột + 6 KpiCard của bản cũ (bảng "cột nào chết, chuyển
* đi đâu" nằm ở `sub-implementer-frontend-s171.md` §4).
* [M-8] Σ tiền in MỘT số, suy từ `phase === DaDuyet` (đúng như BE suy `budgetFrozen`) —
* KHÔNG thêm field vào `KhkkListItemDto`, vì `budgetFrozen` chỉ có ở DetailDto.
*/
export function KhkkPlanCard({
plan,
selected,
onSelect,
finalize,
}: {
plan: KhkkListItemDto
selected: boolean
onSelect: (id: string) => void
/** Chỉ có khi phiếu ĐANG được chọn (detail đã tải) — list payload không mang cờ này. */
finalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const frozen = plan.phase === KhkkPhase.DaDuyet
const amount = frozen ? (plan.totalApprovedAmount ?? plan.totalProposedAmount) : plan.totalProposedAmount
return (
<button
type="button"
onClick={() => onSelect(plan.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
selected && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1">
{finalize && (
<KhkkFinalizeChip
endedByLevelFinalize={finalize.endedByLevelFinalize}
finalizeStepName={finalize.finalizeStepName}
phase={plan.phase}
/>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">
{plan.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</span>
</div>
</div>
<span
className={cn(
'shrink-0 rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
KHKK_PHASE_BADGE[plan.phase],
)}
>
{KHKK_PHASE_LABELS[plan.phase]}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
{plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{plan.approvalGroup}
</span>
)}
<span>·</span>
<span className="truncate">{formatDateTime(plan.createdAt)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] text-slate-500">
<span className="min-w-0 truncate">👤 {plan.drafterFullName ?? '—'}</span>
<span className={cn('shrink-0 tabular-nums', frozen ? 'text-emerald-700' : 'text-brand-800')}>
{formatVnd(amount)}
</span>
</div>
</button>
)
}
export function KhkkListPanel({
selectedId,
onSelect,
search,
onSearchChange,
phase,
onPhaseChange,
group,
onGroupChange,
deletedView,
page,
onPageChange,
selectedFinalize,
}: {
selectedId: string | null
onSelect: (id: string) => void
search: string
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const { canPe } = usePipelineStages()
const term = search.trim()
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm
// ngoài 200 phiếu mới nhất VẪN tới được.
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm
// người dùng mất trắng phiếu của chính họ.
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null
const list = useQuery({
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }],
queryFn: async () => {
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
group: group ?? undefined,
search: term || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
enabled: flatMode,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
// [B1] Thân folder GĐ2 của cây = CARD phiếu của chính trang này; `Select` nhóm lọc luôn
// trên cây (client-side, an toàn: đây chỉ là chia lại đúng tập phiếu cây đã có).
const stage2Content = (plans: KhkkListItemDto[]) => {
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) {
return (
<p className="px-3 py-2 text-[11px] italic text-slate-400">
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
</p>
)
}
return (
<ul className="divide-y divide-slate-100">
{shown.map(k => (
<li key={k.id}>
<KhkkPlanCard
plan={k}
selected={selectedId === k.id}
onSelect={onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined}
/>
</li>
))}
</ul>
)
}
const stage2Count = (plans: KhkkListItemDto[]) =>
(group === null ? plans : plans.filter(k => k.approvalGroup === group)).length
return (
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
{/* Khối filter — CHỖ ĐẶT giống PE/GĐ3 (trên cùng panel 1). `Select` nhóm N1-N8 là
chỗ KHKK khác nguồn dữ-liệu chứ không khác bố-trí (P1-4). */}
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => onSearchChange(e.target.value)}
placeholder="Tìm mã kế hoạch / mã phiếu / gói thầu…"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
{flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400">
Đ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>
)}
{!canPe && (
<p className="text-[10px] italic text-slate-400">
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
sách kế hoạch kết vẫn liệt đy đ dưới.
</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!flatMode && (
<PipelineTreePanel
currentStage={2}
className="border-0 shadow-none"
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>
)}
{flatMode && list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100 motion-reduce:animate-none" />
))}
</div>
)}
{flatMode && list.isError && (
<div className="space-y-2 px-3 py-4">
<p className="text-[12px] text-slate-500">Không tải đưc danh sách kế hoạch kết.</p>
<button
type="button"
onClick={() => list.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
Thử lại
</button>
</div>
)}
{flatMode && !list.isLoading && !list.isError && items.length === 0 && (
<div className="p-6 text-center text-sm text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào khớp bộ lọc.'}
</div>
)}
{flatMode && items.length > 0 && (
<ul className="divide-y divide-slate-100">
{items.map(k => (
<li key={k.id} className={cn(deletedView && 'opacity-70')}>
<KhkkPlanCard
plan={k}
selected={selectedId === k.id}
onSelect={deletedView ? () => { /* phiếu đã xóa: chỉ liệt kê, không mở */ } : onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined}
/>
</li>
))}
</ul>
)}
</div>
{flatMode && totalPages > 1 && (
<div className="flex shrink-0 items-center justify-between border-t border-slate-200 px-3 py-2 text-[11px]">
<span className="text-slate-500">
<FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
</button>
</div>
</div>
)}
</aside>
)
}

View File

@ -0,0 +1,726 @@
// [S171 KHKK 3-panel] PANEL 3 (phải) — quy trình duyệt + lịch sử của "Kế hoạch ký kết HĐ".
// DỜI từ thư mục trang KHKK (file cùng tên) sang `components/khkk/` (cùng lớp với
// `components/pe/PeWorkflowPanel.tsx` của Duyệt NCC — panel dùng ở NHIỀU host thì không
// thuộc về một trang). File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 Khối này là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774` · HĐ
// `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được KHI VÀ CHỈ KHI BE 3 module
// thống-nhất shape `status` precompute — trước đó gộp chỉ đẻ `if (module === …)`.
//
// 7 khối theo bố-cục đích (mirror Ảnh 2 của Duyệt NCC):
// 1 "Quy trình duyệt" + meta {code} v{NN} · {name}
// 2 Banner kết-thúc-sớm ("không qua CEO")
// 3 Sơ đồ Bước → Cấp → NV (chip phòng · ⚑ Duyệt thay CEO · mờ nhánh không chạy · liệt-kê NV)
// 4 Nút Duyệt / Trả lại / Từ chối + Dialog + nút Xóa phiếu
// 5 📎 File đính kèm khi duyệt (upload THẬT — owner chốt QĐ-3)
// 6 Lịch sử duyệt (N)
// 7 Lịch sử thay đổi
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do
// BE precompute. KHKK BE trả CÂY THÔ — panel này TỰ SUY từ cặp con-trỏ:
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order)
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
// Luật so khớp OR-of-N canonical (BE `ContractSigningPlanFeatures.cs:259-260`):
// `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror bằng
// `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.
//
// [R-8] KHKK KHÔNG có phiếu V1-legacy ⇒ KHÔNG port nhánh fallback V1 của PE (code chết).
// [ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH] KHÔNG price-picker (KHKK chốt giá per-Line ở choke-point BE
// `ApplyApprovedValuesOnFinalize`), KHÔNG badge "✎ NS PRO/CCM" (khái niệm riêng PE, Mig 50),
// và đợt này giữ 1 return-mode (về Bước 1 · Cấp 1) — 4 mode của PE là món riêng, còn treo.
// 🔴 [F-1 S166 · RATIFIED @S167] Ô-tích "Cấp này KẾT THÚC" = OPT-IN theo khuôn PE-LIVE S97
// (`PeWorkflowPanel.tsx:61` useState(false)). Default KHÔNG tick ⇒ trình tiếp cấp sau.
import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import {
KHKK_CHANGELOG_ACTION_LABELS, KHKK_CHANGELOG_ENTITY_TYPE_LABELS,
KHKK_PHASE_LABELS, KhkkApprovalDecision, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction,
type KhkkApprovalDto, type KhkkAttachmentDto, type KhkkChangelogDto, type KhkkDetailDto,
type KhkkPhaseValue, type KhkkTransitionActionValue, type KhkkTransitionInput,
type KhkkTransitionResult, type KhkkWorkflowLevelDto,
} from '@/types/khkk'
type NodeStatus = 'Done' | 'Current' | 'Pending'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
function formatSize(b: number): string {
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
}
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
const STATUS_DOT: Record<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
Done: 'border-emerald-200 bg-emerald-50/40',
Current: 'border-brand-300 bg-brand-50/50',
Pending: 'border-slate-200 bg-white',
}
const STATUS_ICON: Record<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
const DECISION_LABEL: Record<number, string> = {
0: 'Gửi duyệt',
1: 'Duyệt',
2: 'Trả lại / Từ chối',
3: 'Tự động duyệt',
}
export function KhkkWorkflowPanel({
plan,
onChanged,
onDeleted,
}: {
plan: KhkkDetailDto
/** gọi sau khi transition thành công — caller refetch detail/list theo id của nó. */
onChanged?: () => void
/** gọi sau khi XÓA phiếu — host 3-panel bỏ chọn, trang chi tiết quay về danh sách. */
onDeleted?: () => void
}) {
const qc = useQueryClient()
const { user } = useAuth()
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
const [comment, setComment] = useState('')
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
// [QĐ-3 / BE-7] File người duyệt đính kèm ngay trong dialog duyệt.
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? []
const approvals = plan.approvals ?? []
const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder
// Chữ ký thật: tra theo `approvalWorkflowLevelId` của các hàng ĐÃ DUYỆT trong lịch sử duyệt
// (BE đã lọc `Pending`). Dùng nguồn này thay vì ý-kiến vì ý-kiến sống ở panel 2 (D-3).
const signedByLevelId = useMemo(() => {
const m = new Map<string, KhkkApprovalDto>()
for (const a of approvals) {
if (a.approvalWorkflowLevelId && a.decision !== KhkkApprovalDecision.Reject) {
if (!m.has(a.approvalWorkflowLevelId)) m.set(a.approvalWorkflowLevelId, a)
}
}
return m
}, [approvals])
const stepStatus = (idx: number): NodeStatus => {
if (isApproved) return 'Done'
if (!isWaiting || curStepIdx == null) return 'Pending'
if (idx < curStepIdx) return 'Done'
if (idx === curStepIdx) return 'Current'
return 'Pending'
}
const levelStatus = (idx: number, order: number): NodeStatus => {
const s = stepStatus(idx)
if (s !== 'Current' || curLevelOrder == null) return s
if (order < curLevelOrder) return 'Done'
if (order === curLevelOrder) return 'Current'
return 'Pending'
}
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này.
const currentLevels: KhkkWorkflowLevelDto[] =
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
: []
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
? steps[curStepIdx]
: null
const isAdmin = user?.roles?.includes('Admin') ?? false
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
// Ô-tích hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel`
// (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC, Admin không có own
// ⇒ level ĐẦU nhóm cùng-order. Soi cờ của level KHÁC là hiện ô-tích nói dối.
const actingLevel = currentLevels.find(l => l.approverUserId === user?.id)
?? (isAdmin ? currentLevels[0] : undefined)
const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy DeptManager Admin`). FE KHÔNG suy được
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
// [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu).
const [showChangelog, setShowChangelog] = useState(false)
const changelogs = useQuery({
queryKey: ['khkk-changelogs', plan.id],
queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 },
})).data,
enabled: showChangelog,
})
const transition = useMutation({
mutationFn: async (a: KhkkTransitionActionValue) => {
const body: KhkkTransitionInput = {
action: a,
comment: comment.trim() || null,
...(a === KhkkTransitionAction.Approve
? { applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true }
: {}),
}
const res = await api.post<KhkkTransitionResult>(`/contract-signing-plans/${plan.id}/transitions`, body)
// [QĐ-3] File đính kèm khi duyệt — upload SAU khi transition thành công (transition
// hỏng thì không đẻ file mồ côi). BE tự ép `purpose=ApprovalAttachment`, FE KHÔNG gửi
// số enum lên (hợp-đồng lane BE §5).
for (const f of pendingFiles) {
const fd = new FormData()
fd.append('file', f)
await api.post(`/contract-signing-plans/${plan.id}/approval-attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
return res.data
},
onSuccess: (res) => {
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
setAction(null)
setComment('')
setPendingFiles([])
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
qc.invalidateQueries({ queryKey: ['khkk-changelogs', plan.id] })
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
onChanged?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const removePlan = useMutation({
mutationFn: async () => api.delete(`/contract-signing-plans/${plan.id}`),
onSuccess: () => {
toast.success('Đã xóa kế hoạch')
setConfirmDelete(false)
qc.invalidateQueries({ queryKey: ['khkk-list'] })
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
onDeleted?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const dialogTitle =
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
: '✗ Từ chối kế hoạch ký kết'
const commentRequired =
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowVersion != null && ` v${plan.workflowVersion}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
)}
</header>
<div className="space-y-4 p-5">
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{plan.endedByLevelFinalize && (
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
<div className="font-medium">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'}
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''}
</div>
<div className="mt-0.5">
{isApproved
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.'
: 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'}
</div>
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
// Gom Cấp CÙNG `order` thành MỘT dòng — OR-of-N: N người, chỉ 1 chữ ký là tiến.
const orders = [...new Set(step.levels.map(l => l.order))].sort((a, b) => a - b)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex flex-wrap items-center gap-1.5 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* Chip PHÒNG của Bước (BE-4 join `Departments`). */}
{step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600">
{step.departmentName}
</span>
)}
</div>
{orders.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{orders.map(order => {
const group = step.levels.filter(l => l.order === order)
const signedRows = group.map(l => signedByLevelId.get(l.id)).filter(Boolean) as KhkkApprovalDto[]
const hasSigned = signedRows.length > 0
const finalizeHere = group.some(l => l.allowApproverFinalize)
// Nhánh KHÔNG CHẠY: phiếu đã duyệt bằng đường kết-thúc-sớm ⇒ các cấp
// sau đó không ai ký. Vẽ MỜ + trạng thái ○ thay vì bịa ✓ cho cả cây.
const skipped = isApproved && plan.endedByLevelFinalize && !hasSigned
const ls: NodeStatus = skipped ? 'Pending' : levelStatus(idx, order)
return (
<li key={`${step.id}-${order}`} className={cn('text-[11px]', skipped && 'opacity-50')}>
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
{skipped && <span className="ml-1.5 text-[10px] font-normal text-slate-500">không phải qua</span>}
{finalizeHere && (
<span
className="ml-1.5 inline-flex items-center rounded bg-violet-100 px-1.5 py-0.5 text-[9px] font-semibold text-violet-800"
title="Cấp này duyệt là KẾT THÚC quy trình — duyệt THAY CEO, không trình lên Ban Giám đốc"
>
Duyệt thay CEO
</span>
)}
</div>
{/* [F-3] Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn
lại `/ Tên` mờ — mirror `PeWorkflowPanel.tsx:453-465`. */}
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
{group.length === 0
? '(chưa cấu hình)'
: group.map((l, i) => {
const sign = signedByLevelId.get(l.id)
return (
<span key={l.id} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">/</span>}
<span className={cn(sign ? 'font-semibold text-slate-800' : 'text-slate-400')}>
{l.approverFullName ?? '(chưa cấu hình)'}
</span>
{sign && (
<span className="text-[10px] text-emerald-600"> {formatDateTime(sign.approvedAt)}</span>
)}
</span>
)
})}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Quy trình duyệt chưa cấu hình Bước/Cấp liên hệ Admin đ dựng quy trình loại
&ldquo;Kế hoạch kết &rdquo; trước khi trình duyệt.
</div>
)}
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{phase === KhkkPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
Kế hoạch bị TRẢ LẠI sửa nội dung rồi bấm &ldquo;Gửi duyệt&rdquo;, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{phase === KhkkPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch đã bị TỪ CHỐI không thao tác đưc nữa. Lập kế hoạch mới nếu cần làm lại.
</div>
)}
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
{(canSubmit || isWaiting || canDeletePlan) && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && (
<>
<button
type="button"
disabled={blockedByLevel}
onClick={() => {
if (!blockedByLevel) {
setComment('')
setPendingFiles([])
// reset về default KHÔNG-tick (opt-IN, khuôn PE S97) mỗi lần mở dialog.
setApplyLevelFinalize(false)
setAction(KhkkTransitionAction.Approve)
}
}}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
<Check className="h-3 w-3" /> Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Reject) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-red-300 text-red-700 hover:bg-red-50',
)}
>
<X className="h-3 w-3" /> Từ chối
</button>
</>
)}
{canDeletePlan && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-bold text-slate-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</div>
</div>
)}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{approvalFiles.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Paperclip className="h-3.5 w-3.5 text-teal-500" />
File đính kèm khi duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvalFiles.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{approvalFiles.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="min-w-0">
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<a
href={`/api/contract-signing-plans/${plan.id}/attachments/${a.id}/download`}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
>
Tải xuống
</a>
</li>
))}
</ul>
</div>
)}
{/* ── 6. Lịch sử duyệt (N) ───────────────────────────────────────────── */}
{/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây
CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn
còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Check className="h-3.5 w-3.5 text-emerald-500" />
Lịch sử duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvals.length}
</span>
</div>
{approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : (
<ul className="mt-1.5 space-y-1.5">
{approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className={cn(
'rounded px-1.5 py-0.5 text-[9px] font-semibold',
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-0.5 text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
</span>
</div>
{a.comment?.trim() && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div>
)}
</li>
))}
</ul>
)}
</div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div>
<button
type="button"
onClick={() => setShowChangelog(v => !v)}
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
<History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span>
</button>
{showChangelog && (
<div className="mt-1.5">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && (
<p className="text-[11px] italic text-slate-400">Chưa thay đi nào đưc ghi.</p>
)}
<ul className="space-y-1">
{(changelogs.data ?? []).map(c => (
<li key={c.id} className="rounded border border-slate-100 bg-white px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-600">
<span className="font-medium text-slate-700">
{KHKK_CHANGELOG_ACTION_LABELS[c.action] ?? 'Thay đổi'}
</span>
<span className="text-slate-400">·</span>
<span>{KHKK_CHANGELOG_ENTITY_TYPE_LABELS[c.entityType] ?? 'Khác'}</span>
<span className="text-slate-400">·</span>
<span>{c.userName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(c.createdAt)}</span>
</div>
{(c.summary || c.contextNote) && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{c.summary}
{c.contextNote ? `${c.contextNote}` : ''}
</div>
)}
</li>
))}
</ul>
</div>
)}
</div>
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === KhkkTransitionAction.Submit && (
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
Kế hoạch chuyển sang &ldquo;Chờ duyệt&rdquo; trình lên Bước 1 · Cấp 1 của quy trình
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa đưc nữa.
</div>
)}
{action === KhkkTransitionAction.Return && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Kế hoạch về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
{action === KhkkTransitionAction.Reject && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
</div>
)}
{action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input type="checkbox" className="mt-0.5" checked={applyLevelFinalize} onChange={e => setApplyLevelFinalize(e.target.checked)} />
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Đã tích — duyệt xong là kế hoạch "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
: 'Mặc định: duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này.'}
</span>
</span>
</label>
</div>
)}
<Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
/>
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && (
<div className="mt-3">
<Label className="text-xs">File đính kèm (tuỳ chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => {
setPendingFiles(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-50"
>
<Paperclip className="h-3 w-3" /> Chọn file
</button>
{pendingFiles.length === 0
? <span className="text-[11px] text-slate-400">Chưa chọn file nào</span>
: (
<span className="text-[11px] text-slate-600">
{pendingFiles.map(f => f.name).join(', ')}
<button type="button" onClick={() => setPendingFiles([])} className="ml-2 text-red-600 underline">bỏ chọn</button>
</span>
)}
</div>
<p className="mt-1 text-[11px] text-slate-500">
File đưc tải lên SAU khi thao tác duyệt thành công hiện mục &ldquo;File đính kèm khi duyệt&rdquo;.
</p>
</div>
)}
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ).
</p>
)}
</Dialog>
)}
{confirmDelete && (
<Dialog
open
onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết"
footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Huỷ</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button>
</>}
>
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
Phiếu <span className="font-mono font-semibold">{plan.maKeHoach ?? '(chưa cấp mã)'}</span> sẽ bị
xóa mềm: biến khỏi danh sách khỏi cây toàn trình, chỉ còn xem đưc mục &ldquo;Đã xóa&rdquo;.
</div>
</Dialog>
)}
</section>
)
}

View File

@ -13,7 +13,7 @@ import { useQuery } from '@tanstack/react-query'
import { ListTree, Search } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { usePipelineStages } from '@/hooks/usePipelineStages'
import { usePipelineStages, type BuildStagesOptions } from '@/hooks/usePipelineStages'
import { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders'
import type { Paged } from '@/types/master'
import type { PeListItem } from '@/types/purchaseEvaluation'
@ -87,9 +87,14 @@ export function PipelineTreePanel({
currentStage,
className,
bodyMaxHClass,
stage2Options,
}: {
currentStage: PipelineStageNo
className?: string
// [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt
// sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk'>
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
@ -306,7 +311,7 @@ export function PipelineTreePanel({
className="ml-3 border-l border-slate-200"
currentStage={currentStage}
hideEmpty={hideEmpty}
stages={buildStages(wg.items)}
stages={buildStages(wg.items, stage2Options ?? {})}
/>
</details>
)

View File

@ -162,6 +162,27 @@ export type BuildStagesOptions = {
/** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
stage1Content?: ReactNode
stage1Count?: number
// ── [S171 — mitigation B1 + M-2, owner chốt panel 1 KHKK = cây] ─────────────
/**
* GĐ2 tự vẽ (trang Kế hoạch ký kết truyền khối CARD phiếu của chính nó).
* 🔴 TRADE-OFF ĐÃ KHAI: `PipelineStageFolders.tsx:285-287` đặt `content` TRƯỚC `groups`
* trong chuỗi tam-nguyên ⇒ truyền cái này thì 8 ngăn N1-N8 KHÔNG vẽ nữa (im lặng, không
* lỗi). Chấp nhận được VÌ lối vào nhóm giữ ở `Select` nhóm của khối filter panel 1.
* ADDITIVE: `PurchaseEvaluationsListPage` không truyền ⇒ trang PE/GĐ3 GIỮ NGUYÊN 8 ngăn.
*/
stage2Content?: (items: KhkkListItemDto[]) => ReactNode
/**
* 🔴 BẮT BUỘC đi kèm `stage2Content` — `stageCount()` (`PipelineStageFolders.tsx:95-97`)
* đọc `count ?? 0` khi có `content`, thiếu là badge về 0 trong khi folder đầy phiếu.
* Mặc định (không truyền) = số phiếu của gói, KHÔNG bao giờ để rơi về 0.
*/
stage2Count?: (items: KhkkListItemDto[]) => number
/**
* [M-2] Bấm leaf/card KHKK ⇒ HOST xử lý (chọn vào panel 2 của màn 3-panel) thay vì
* `navigate('/khkk/:id')` rời trang. KHÔNG truyền ⇒ giữ NGUYÊN hành vi cũ (navigate)
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
*/
onOpenKhkk?: (id: string) => void
}
/**
@ -208,7 +229,8 @@ export function usePipelineStages() {
secondary: k.peTenGoiThau,
badgeLabel: KHKK_PHASE_LABELS[k.phase],
badgeClass: KHKK_PHASE_BADGE[k.phase],
onOpen: () => navigate(`/khkk/${k.id}`),
// [S171 M-2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
onOpen: opts.onOpenKhkk ? () => opts.onOpenKhkk!(k.id) : () => navigate(`/khkk/${k.id}`),
})
const khkkLeaves = khkkItems.map(toKhkkLeaf)
@ -265,7 +287,14 @@ export function usePipelineStages() {
const stage2: PipelineStage = {
n: 2,
leaves: khkkLeaves,
groups: khkkGroups,
// [S171 B1] `content` ⇒ thân folder do host vẽ (card phiếu KHKK) và `groups` bị bỏ
// qua; `count` PHẢI đi cùng, mặc định = số phiếu của gói (không để rơi về 0).
...(opts.stage2Content !== undefined
? {
content: opts.stage2Content(khkkItems),
count: (opts.stage2Count ?? (its => its.length))(khkkItems),
}
: { groups: khkkGroups }),
locked: !linkage.canKhkk,
failed: linkage.khkkFailed,
loading: linkage.khkkLoading,

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,47 @@
// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2).
// Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
// fe-admin counterpart.
// [S171] HOST 3-PANEL của màn "Kế hoạch ký kết HĐ" (GĐ2) — đưa GĐ2 về CÙNG bố-cục với
// Duyệt NCC (GĐ1, `pages/pe/PurchaseEvaluationsListPage.tsx:381,397`) và Hợp đồng
// (GĐ3, `pages/contracts/ContractsListPage.tsx:106,125`). Trước S171 trang này là
// `space-y-5` cuộn-body 2 panel ⇒ mắt-xích lệch DUY NHẤT trong 4 giai-đoạn.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// URL vào từ menu (Layout.tsx — staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm
// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
// Khuôn code lấy từ `ContractsListPage.tsx` (255 dòng, gọn) — CỐ Ý KHÔNG chép
// `PurchaseEvaluationsListPage.tsx` (807 dòng: kéo theo `deleted`/`pendingMe`/`awId`/overlay).
// Số đo panel lấy của PE (400px) vì hiến-pháp owner là "giống nhất với Duyệt NCC".
//
// URL vào từ menu (Layout.tsx — staticMap nhóm 1, regex `Khkk_G{n}_*` nhóm 2..8):
// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
// 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng
// nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc).
//
// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5).
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
import { useMemo, useState, type ReactNode } from 'react'
// 🔴 [M-4] KHOÁ URL ĐƯỢC PHÉP GHI = `id` (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`).
// TUYỆT ĐỐI KHÔNG ghi `filter` / `view` / `group` vào `setSearchParams`: đứng leaf
// `?group=1` mà ghi thêm `filter=DaDuyet` là URL khớp CHÍNH XÁC leaf "Đã duyệt" của nhóm ⇒
// sidebar sáng nhầm mục — đúng bug UAT S155. Bộ lọc người dùng bấm sống ở LỚP OVERRIDE
// (state) như bản cũ, không ghi ngược URL.
//
// 🔴 [K4b S166 · R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER. Khối reset dưới đây là SET-STATE-TRONG-RENDER **CÓ CHỦ ĐÍCH** (khuôn React
// "reset state khi input đổi") — ai refactor thành `useEffect` sẽ tái-hiện vá-5 (đi từ
// leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Plus, Search, FileCheck, FileEdit, SendHorizontal,
CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { FileCheck, Plus, X } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUP_LABELS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkDetailDto,
type KhkkPhaseValue,
type PagedResult,
} from '@/types/khkk'
const PAGE_SIZE = 20
// Tên phase trong query `?filter=` → int (URL đọc được, không dính số ma thuật).
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
DangSoanThao: KhkkPhase.DangSoanThao,
@ -53,308 +51,156 @@ const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
TuChoi: KhkkPhase.TuChoi,
}
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function KhkkListPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const { user } = useAuth()
// ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ----
// Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi
// query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại.
// Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5).
// ---- 3 trục DANH TÍNH đọc thẳng từ URL (chỉ ĐỌC — không bao giờ ghi lại, xem M-4) ----
const urlFilter = searchParams.get('filter')
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
const deletedView = searchParams.get('view') === 'deleted'
// `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1,
// vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh).
const groupParam = searchParams.get('group')
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const urlGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const selectedId = searchParams.get('id')
// ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL;
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
// ---- Lớp override do NGƯỜI DÙNG bấm, đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa chạm ⇒ nghe theo URL; `null` = user
// chủ động chọn "Tất cả"; số = user chọn một giá trị. Gộp `undefined` với `null` thì chọn
// "Tất cả" sẽ bị URL kéo ngược về bộ lọc cũ.
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
const [groupOverride, setGroupOverride] = useState<number | null | undefined>(undefined)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
// Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đng trang 5 của nhóm 2
// mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
// vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm
// một mã rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
// Đổi leaf ⇒ trả bộ lọc về cho URL và về trang 1. `search` CỐ Ý giữ lại: đang tìm một mã
// rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${urlGroup ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
const [lastNavKey, setLastNavKey] = useState(navKey)
if (navKey !== lastNavKey) {
setLastNavKey(navKey)
setChipPhase(undefined)
setGroupOverride(undefined)
setPage(1)
}
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
const group: number | null = groupOverride !== undefined ? groupOverride : urlGroup
const list = useQuery({
// 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
// 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
// có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là
// `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server)
// ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô
// cache cho hai request y hệt nhau.
queryKey: ['khkk-list', { group, phase, search, page, deletedView }],
queryFn: async () => {
// Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng
// `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155).
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
// [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror
// List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery`
// + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server,
// total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa.
group: group ?? undefined,
search: search.trim() || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
const detail = useQuery({
queryKey: ['khkk-detail', selectedId],
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${selectedId}`)).data,
enabled: !!selectedId,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
function setId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('id')
else next.set('id', id)
setSearchParams(next)
}
// Presentation-only: đếm trên trang đang tải (không fetch thêm). Card đang active
// hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh.
const countByPhase = useMemo(() => {
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 }
for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1
return acc
}, [items])
function selectPlan(id: string) {
// `<lg` không có 3 panel ⇒ mở trang chi tiết đầy đủ (route `/khkk/:id` GIỮ NGUYÊN).
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setId(id)
} else {
navigate(group !== null ? `/khkk/${id}?group=${group}` : `/khkk/${id}`)
}
}
const phaseCards: Array<{
value: KhkkPhaseValue | null
label: string
icon: ReactNode
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
count: number
}> = [
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
{ value: KhkkPhase.DaDuyet, label: KHKK_PHASE_LABELS[3], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: phase === 3 ? total : countByPhase[3] },
{ value: KhkkPhase.TraLai, label: KHKK_PHASE_LABELS[98], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: phase === 98 ? total : countByPhase[98] },
{ value: KhkkPhase.TuChoi, label: KHKK_PHASE_LABELS[99], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: phase === 99 ? total : countByPhase[99] },
]
const plan = detail.data
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
const readOnly =
!plan
|| !(plan.phase === KhkkPhase.DangSoanThao || plan.phase === KhkkPhase.TraLai)
|| (!!user?.id && plan.drafterUserId !== user.id)
const headerTitle = deletedView
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null ? `Kế hoạch ký kết HĐ — N${group}` : 'Kế hoạch ký kết HĐ'
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title={
deletedView
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null
? `Kế hoạch ký kết HĐ — N${group}`
: 'Kế hoạch ký kết HĐ'
}
subtitle={
// [K4b S166] Màn "Đã xóa" nay ĐƯỢC in tên nhóm — `/deleted` đã lọc server-side
// theo `group` (FLAG-1 nối cùng lượt), tên nhóm hết là lời nói dối.
deletedView
? (group !== null ? `${khkkGroupMenuLabel(group)} — phiếu đã xóa mềm, chỉ xem` : 'Phiếu đã xóa mềm — chỉ xem danh sách')
: group !== null
? khkkGroupMenuLabel(group)
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
}
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
accent="brand"
actions={
deletedView ? undefined : (
// Mang nhóm đang xem sang màn lập phiếu — không bắt người dùng chọn lại
// đúng cái nhóm họ vừa đứng (bắt tay với vá-6 bên `KhkkCreatePage`).
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" />
<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">
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''}
</span>
</div>
{!deletedView && (
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
<Plus className="mr-2 h-4 w-4" />
Lập kế hoạch
</Button>
)
)}
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1 — cây toàn trình (owner QĐ-1) + 4 mitigation, xem KhkkListPanel.tsx */}
<KhkkListPanel
selectedId={selectedId}
onSelect={selectPlan}
search={search}
onSearchChange={(v) => { setSearch(v); setPage(1) }}
phase={phase}
onPhaseChange={(v) => { setChipPhase(v); setPage(1) }}
group={group}
onGroupChange={(v) => { setGroupOverride(v); setPage(1) }}
deletedView={deletedView}
page={page}
onPageChange={setPage}
selectedFinalize={
plan
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
: undefined
}
/>
{/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner
chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng
8 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
<PipelineTreePanel currentStage={2} />
<div className="min-w-0 space-y-5">
{!deletedView && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{phaseCards.map((c) => (
<KpiCard
key={c.value ?? 'all'}
label={c.label}
value={c.count}
icon={c.icon}
accent={c.accent}
active={phase === c.value}
onClick={() => {
// Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng
// để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file).
setChipPhase(c.value)
setPage(1)
}}
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState
icon={FileCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
description={deletedView
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không thao tác.'
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
/>
))}
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !plan && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{selectedId && plan && (
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} />
)}
</main>
{/* Panel 3 — quy trình duyệt + lịch sử.
🔴 OWNER CHỐT (QĐ-2): chưa chọn phiếu ⇒ CHỈ hộp dashed y hệt Duyệt NCC
(`PurchaseEvaluationsListPage.tsx:669-676`). KHÔNG nhét `PipelineTreePanel` vào
đây — cây đã sống ở panel 1 theo QĐ-1, nhét lần hai là trùng. Khác GĐ3 ở điểm
này là CÓ CHỦ ĐÍCH. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
{deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div>
)}
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã kế hoạch, mã phiếu hoặc tên gói thầu..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
{selectedId && plan && (
<KhkkWorkflowPanel
plan={plan}
onChanged={() => detail.refetch()}
onDeleted={() => setId(null)}
/>
</div>
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 8 cột cuộn ngang trong thẻ thay vì
tràn ra ngoài lưới trên laptop 1366. */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-4 py-2.5 text-left"> kế hoạch</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đ xuất</th>
{/* [K2 S164 — vá-8] Σ `ApprovedAmount` (`ListItemDto:64`). Không có cột này
thì màn danh sách nói "Tổng đề xuất" còn màn chi tiết nói "Giá duyệt"
trên CÙNG một phiếu đã duyệt ⇒ người dùng đọc ra hai con số. */}
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng duyệt</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
</tr>
</thead>
<tbody>
{list.isLoading && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
Đang tải...
</td>
</tr>
)}
{!list.isLoading && items.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào.'}
</td>
</tr>
)}
{items.map((k) => (
<tr
key={k.id}
onClick={() => !deletedView && navigate(`/khkk/${k.id}`)}
className={cn(
'border-b border-slate-100 transition last:border-0',
deletedView ? 'opacity-70' : 'cursor-pointer hover:bg-brand-50/50',
)}
>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-2">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileCheck className="h-3.5 w-3.5" />
</span>
<span className="font-mono text-xs text-brand-800">{k.maKeHoach ?? '—'}</span>
{/* [K2 S164] Badge NHÓM DUYỆT — đặt cạnh mã thay vì đẻ cột thứ 9:
bảng này đã cuộn ngang ở 1366 khi có cây bên trái (ghi chú S162).
`!= null` guard cho payload cũ còn trong cache React Query (trước
K2 DTO chưa có field) — thiếu guard thì in ra "Nundefined". */}
{k.approvalGroup != null && (
<span
className="inline-flex items-center rounded border border-violet-300 bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
>
N{k.approvalGroup}
</span>
)}
</span>
</td>
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-brand-800">
{k.peTenGoiThau ?? '—'}
{k.peMaPhieu && <span className="ml-2 font-mono text-[11px] text-slate-400">{k.peMaPhieu}</span>}
</td>
<td className="max-w-[14rem] truncate px-4 py-2.5 text-xs text-slate-600">{k.projectName ?? '—'}</td>
<td className="px-4 py-2.5">
<span
className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
KHKK_PHASE_BADGE[k.phase as KhkkPhaseValue],
)}
>
{KHKK_PHASE_LABELS[k.phase as KhkkPhaseValue]}
</span>
{k.currentApprovalLevelOrder && (
<span className="ml-2 text-xs text-slate-500">Cấp {k.currentApprovalLevelOrder}</span>
)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(k.totalProposedAmount)}</td>
{/* Phiếu chưa duyệt BE trả 0 (không phải null) ⇒ ép về "—" cho khỏi đọc
nhầm "đã duyệt 0 đ"; số chỉ hiện khi thật sự có tiền đã chốt. */}
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-700">
{k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'}
</td>
<td className="px-4 py-2.5 text-xs text-slate-600">{k.drafterFullName ?? '—'}</td>
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(k.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-slate-500">
{total} kế hoạch Trang {page} / {totalPages}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Sau
</Button>
</div>
</div>
)}
</div>
</div>
</aside>
</div>
</div>
)

View File

@ -1,491 +0,0 @@
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
// choke-point, KHÔNG phải việc của panel này.
//
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
// 🔴 [F-1 S166 — SUPERSEDE tiếp] Câu cũ "panel không có control riêng cho đường kết-thúc-sớm" HẾT hiệu lực:
// gate-K3 chấm nó là F-1 MAJOR (BE `?? true` mà 0 UI gửi false ⇒ trạm CEO chỉ đi được bằng admin bỏ cờ
// Designer). Nay panel CÓ ô-tích "Cấp này KẾT THÚC" theo khuôn PE-LIVE S97 = OPT-IN (G-1 reviewer:
// PE `useState(false)` :61 — owner đã ĐẢO opt-out S96 → opt-in S97; nhãn cũ "khuôn S96" trích trúng
// chú-thích chết). Default KHÔNG tick ⇒ gửi false ⇒ trình tiếp; TÍCH ⇒ true ⇒ BE kết thúc tại cấp.
// RATIFIED @S167 2026-08-01 (anh: "Đồng ý. Cứ theo khuôn PE") — default OPT-IN khuôn PE-live-S97 là CHỐT.
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import {
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
} from '@/types/khkk'
type NodeStatus = 'Done' | 'Current' | 'Pending'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
const STATUS_DOT: Record<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
Done: 'border-emerald-200 bg-emerald-50/40',
Current: 'border-brand-300 bg-brand-50/50',
Pending: 'border-slate-200 bg-white',
}
const STATUS_ICON: Record<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
export function KhkkWorkflowPanel({
plan,
onChanged,
}: {
plan: KhkkDetailDto
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
onChanged?: () => void
}) {
const qc = useQueryClient()
const { user } = useAuth()
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
const [comment, setComment] = useState('')
// [F-1 S166 · G-1 reviewer] Ô-tích level-finalize theo khuôn PE-LIVE S97 = OPT-IN
// (`PeWorkflowPanel.tsx:61` useState(false) — owner đảo opt-out S96 → opt-in S97 "cái này option
// thôi"; đừng trích comment S96 chết ở `:769-771` file đó). Default UNCHECKED = duyệt xong TRÌNH
// TIẾP cấp sau (fail-mode an toàn: không đọc thì phiếu vẫn lên CEO); TÍCH mới KẾT THÚC tại cấp.
// Hành vi mặc định trạm CCM đổi so trước-F-1 (BE `?? true` auto-finalize) — RATIFIED @S167 (khuôn PE);
// lật lại (nếu đổi ý) = đổi 1 chữ false→true + 2 nhãn dưới.
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? []
const opinions = plan.levelOpinions ?? []
const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
const stepStatus = (idx: number): NodeStatus => {
if (isApproved) return 'Done'
if (!isWaiting || curStepIdx == null) return 'Pending'
if (idx < curStepIdx) return 'Done'
if (idx === curStepIdx) return 'Current'
return 'Pending'
}
const levelStatus = (idx: number, order: number): NodeStatus => {
const s = stepStatus(idx)
if (s !== 'Current' || curLevelOrder == null) return s
if (order < curLevelOrder) return 'Done'
if (order === curLevelOrder) return 'Current'
return 'Pending'
}
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
const currentLevels: KhkkWorkflowLevelDto[] =
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
: []
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
? steps[curStepIdx]
: null
const isAdmin = user?.roles?.includes('Admin') ?? false
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
// [F-1 S166] Ô-tích opt-out hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel`
// (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC (kể cả Admin có slot riêng),
// Admin không có own ⇒ level ĐẦU nhóm cùng-order. Soi cờ của level KHÁC là hiện ô-tích nói dối.
const actingLevel = currentLevels.find(l => l.approverUserId === user?.id)
?? (isAdmin ? currentLevels[0] : undefined)
const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor DeptManager(cùng phòng) Admin`
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const transition = useMutation({
mutationFn: async (a: KhkkTransitionActionValue) => {
// 🔴 BODY theo hợp-đồng LEAD chốt W3 (`action` + `comment`) + [F-1 S166] field thứ 3
// `applyLevelFinalize` CHỈ đính kèm khi approve: cấp có cờ gửi giá trị ô-tích, cấp thường
// gửi true = no-op (mirror PE `PeWorkflowPanel.tsx:259`; BE `bool? = null` ⇒ absent = true).
const body: KhkkTransitionInput = {
action: a,
comment: comment.trim() || null,
...(a === KhkkTransitionAction.Approve
? { applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true }
: {}),
}
const res = await api.post<KhkkTransitionResult>(
`/contract-signing-plans/${plan.id}/transitions`,
body,
)
return res.data
},
onSuccess: (res) => {
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
setAction(null)
setComment('')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
onChanged?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const dialogTitle =
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
: '✗ Từ chối kế hoạch ký kết'
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
const commentRequired =
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
)}
</header>
<div className="space-y-4 p-5">
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex items-center gap-2 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
</div>
{step.levels.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{step.levels.map(lv => {
const ls = levelStatus(idx, lv.order)
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<li key={lv.id} className="text-[11px]">
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{lv.name || `Cấp ${lv.order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
</div>
<div className="text-slate-500">
{lv.approverFullName ?? '(chưa cấu hình)'}
{signed && (
<span className="ml-1.5 text-[10px] text-emerald-600">
{formatDateTime(signed.signedAt)}
</span>
)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Quy trình duyệt chưa cấu hình Bước/Cấp liên hệ Admin đ dựng quy trình loại
&ldquo;Kế hoạch kết &rdquo; trước khi trình duyệt.
</div>
)}
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{phase === KhkkPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
Kế hoạch bị TRẢ LẠI sửa nội dung rồi bấm &ldquo;Gửi duyệt&rdquo;, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{phase === KhkkPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch đã bị TỪ CHỐI không thao tác đưc nữa. Lập kế hoạch mới nếu cần làm lại.
</div>
)}
{/* Hành động */}
{(canSubmit || isWaiting) && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && (
<>
<button
type="button"
disabled={blockedByLevel}
onClick={() => {
if (!blockedByLevel) {
setComment('')
// [F-1 S166 · G-1] reset về default KHÔNG-tick (opt-IN, khuôn PE S97) mỗi
// lần mở dialog — lựa chọn tích là per-lượt-duyệt, không dính lần trước.
setApplyLevelFinalize(false)
setAction(KhkkTransitionAction.Approve)
}
}}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
<Check className="h-3 w-3" /> Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-red-300 text-red-700 hover:bg-red-50',
)}
>
<X className="h-3 w-3" /> Từ chối
</button>
</>
)}
</div>
</div>
)}
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
{opinions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
Ý kiến cấp duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{opinions.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{opinions.map(o => (
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{o.signedByFullName}</span>
<span className="text-slate-400">·</span>
<span>
{o.stepName ? `Bước ${o.stepOrder}${o.stepName}` : 'Cấp duyệt'}
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay
</span>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{o.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
</li>
))}
</ul>
</div>
)}
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === KhkkTransitionAction.Submit && (
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
Kế hoạch chuyển sang &ldquo;Chờ duyệt&rdquo; trình lên Bước 1 · Cấp 1 của quy trình
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa đưc nữa.
</div>
)}
{action === KhkkTransitionAction.Return && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Kế hoạch về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
{action === KhkkTransitionAction.Reject && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
</div>
)}
{/* [F-1 S166 · G-1/G-5 reviewer] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) = ô-tích
OPT-IN, khuôn PE-LIVE S97 (`PeWorkflowPanel.tsx:61` useState(false) + JSX `:772-791`;
comment "opt-out S96" ngay trên khối JSX đó là chú-thích CHẾT — G-1 bắt đúng bẫy này):
default KHÔNG tick = duyệt xong TRÌNH TIẾP cấp sau; TÍCH = gửi applyLevelFinalize=true
⇒ BE kết thúc tại cấp. Không viết "(CEO)" — cấp-sau là gì tuỳ cấu hình quy trình (G-5).
KHÁC PE: không nhắc "chọn giá chốt" — KHKK chốt giá per-dòng do BE tự làm ở finalize
choke-point (`ApplyApprovedValuesOnFinalize`), panel không có price-picker. */}
{action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5"
checked={applyLevelFinalize}
onChange={e => setApplyLevelFinalize(e.target.checked)}
/>
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Đã tích — duyệt xong là kế hoạch "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
: 'Mặc định: duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này.'}
</span>
</span>
</label>
</div>
)}
<Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
/>
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ).
</p>
)}
</Dialog>
)}
</section>
)
}

View File

@ -103,6 +103,10 @@ export const DOSSIER_STATUS_BADGE: Record<DossierItemStatusValue, string> = {
export const KhkkAttachmentPurpose = {
DossierScan: 1,
PlanDocument: 2,
// [S171 BE-7] File người DUYỆT đính kèm lúc bấm Duyệt/Trả lại/Từ chối (panel 3 mục 5).
// Upload qua đường RIÊNG `POST /contract-signing-plans/{id}/approval-attachments` —
// BE tự ép purpose, FE KHÔNG gửi số enum này lên.
ApprovalAttachment: 3,
Other: 99,
} as const
export type KhkkAttachmentPurposeValue =
@ -111,6 +115,7 @@ export type KhkkAttachmentPurposeValue =
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
1: 'Scan căn cứ',
2: 'Bản kế hoạch',
3: 'File duyệt',
99: 'Khác',
}
@ -272,13 +277,88 @@ export interface KhkkWorkflowLevelDto {
allowApproverFinalize: boolean
}
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
// Mirror `ContractSigningPlanWorkflowStepDto`.
export interface KhkkWorkflowStepDto {
id: string
order: number
name: string
departmentId: string | null
levels: KhkkWorkflowLevelDto[]
// [S171 BE-4] Tên phòng của Bước (BE join `Departments`). Nuôi ĐỒNG THỜI 2 chỗ:
// chip phòng ở panel 3 (P3-4) và header nhóm Section 5 (P2-5). null = Bước không gắn phòng.
departmentName: string | null
}
// [S171 BE-2] Lịch sử duyệt — panel 3 mục 6 "Lịch sử duyệt (N)" của Ảnh 2.
// 🔴 BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) để số `(N)` cùng nghĩa với `(N)`
// của PE. FE KHÔNG cần dedupe lại. Sự kiện "Gửi duyệt" vẫn còn ở `changelogs`.
export const KhkkApprovalDecision = {
Pending: 0,
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
export type KhkkApprovalDecisionValue =
(typeof KhkkApprovalDecision)[keyof typeof KhkkApprovalDecision]
export interface KhkkApprovalDto {
id: string
fromPhase: KhkkPhaseValue
toPhase: KhkkPhaseValue
decision: KhkkApprovalDecisionValue
comment: string | null
approvalWorkflowLevelId: string | null
// 4 field dưới DẪN XUẤT từ `approvalWorkflowLevelId` (loose-Guid, no-FK) qua cây workflow
// đã pin ⇒ null khi Cấp đã bị admin xoá. Dòng lịch sử VẪN hiện (BE không nuốt hàng).
stepOrder: number | null
stepName: string | null
levelOrder: number | null
levelName: string | null
approvedByUserId: string | null
approvedByFullName: string | null
approvedAt: string
}
// [S171 BE-5] "Lịch sử thay đổi" — `GET /contract-signing-plans/{id}/changelogs`
// (policy `KeHoachKyKet.Read` + rào hàng `EnsureCanViewAsync` ở handler).
export const KhkkChangelogEntityType = {
Header: 1,
Line: 2,
DossierItem: 3,
Workflow: 4,
Attachment: 5,
} as const
export type KhkkChangelogEntityTypeValue =
(typeof KhkkChangelogEntityType)[keyof typeof KhkkChangelogEntityType]
export const KHKK_CHANGELOG_ENTITY_TYPE_LABELS: Record<number, string> = {
1: 'Thông tin chung',
2: 'Dòng giá',
3: 'Căn cứ hồ sơ',
4: 'Quy trình',
5: 'File đính kèm',
}
// Mirror `ChangelogAction` (enum DÙNG CHUNG 3 module — `Domain/Contracts/ChangelogAction`).
export const KHKK_CHANGELOG_ACTION_LABELS: Record<number, string> = {
1: 'Tạo',
2: 'Cập nhật',
3: 'Xóa',
4: 'Chuyển trạng thái',
}
export interface KhkkChangelogDto {
id: string
entityType: KhkkChangelogEntityTypeValue
entityId: string | null
action: number
phaseAtChange: KhkkPhaseValue | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}
export interface KhkkDetailDto extends KhkkListItemDto {
@ -296,9 +376,23 @@ export interface KhkkDetailDto extends KhkkListItemDto {
// [K2 S164] `ContractSigningPlanDetailDto:166` — CỜ HIỂN THỊ dẫn xuất từ phase
// (`phase == DaDuyet`), KHÔNG phải cột DB. Số đã đóng băng ở tầng dữ liệu bởi
// `ApprovedAmount ??=` tại choke-point finalize. Khuôn tên `budgetFrozen` của PE S133.
// 🔸 BE **KHÔNG** expose `endedByLevelFinalize` ở DTO lượt này (cột Mig 71 nằm im chờ
// K3 tiêu thụ — ghost-window đã khai ở `sub-implbe-k2.md §a`) ⇒ FE không khai field đó.
budgetFrozen: boolean
// ===================== [S171 BE-1/BE-1b/BE-2] 5 field nuôi panel 3 ====================
// 🔸 Đính chính ghi-chú K2 cũ ("BE KHÔNG expose `endedByLevelFinalize`"): từ S171 CÓ expose.
/** Số phiên bản quy trình đã pin — dòng meta `{workflowCode} v{NN} · {workflowName}`. */
workflowVersion: number | null
/**
* Banner "kết thúc sớm — không qua CEO". 3-NGẢ ở BE, FE chỉ đọc:
* phiếu `DaDuyet` → CỜ RUNTIME thật (Mig 71); phiếu chưa xong → suy từ cấu hình nhưng
* POINTER-AWARE (chỉ tính cấp `allowApproverFinalize` ở-hoặc-SAU con-trỏ); chưa pin quy trình → false.
*/
endedByLevelFinalize: boolean
/** Tên Bước/Cấp có `allowApproverFinalize` ĐẦU TIÊN — nhãn CẤU HÌNH, KHÔNG có cột DB nào lưu. */
finalizeStepName: string | null
finalizeLevelName: string | null
/** Lịch sử duyệt (BE đã lọc `decision = Pending`). */
approvals: KhkkApprovalDto[]
}
// [K7 S167 — vá-4b] Tập id HỢP ĐỒNG mà một phiếu KHKK đã đẻ ra. Gom từ MỌI nguồn CÓ THẬT

View File

@ -0,0 +1,709 @@
// [S171 KHKK 3-panel] PANEL 2 (giữa) — nội-dung phiếu "Kế hoạch ký kết HĐ" (GĐ2).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 Khối ý-kiến cấp duyệt (Section 5) là bản thứ 3 của cùng một hình (PE
// `PeDetailTabs.tsx:705-774` · HĐ `components/contracts/WorkflowHistoryPanel.tsx`).
// Gộp được KHI VÀ CHỈ KHI BE 3 module thống-nhất shape `status` precompute — trước đó
// gộp chỉ đẻ ra `if (module === …)`.
//
// Bố-cục mirror Duyệt NCC: dải 4 giai-đoạn trên cùng, rồi các Section ĐÁNH SỐ nối nhau
// bằng `divide-y`, ý-kiến cấp duyệt nằm DƯỚI CÙNG (Ảnh 3 khoanh đỏ).
// 1. Thông tin kế hoạch 4. File đính kèm
// 2. Giá đề xuất theo NCC 5. Ý kiến cấp duyệt
// 3. Căn cứ hồ sơ (b.8-9)
// 🔸 KHÁC PE ở NỘI DUNG (miễn-trừ P2-3, có lý-do nghiệp-vụ): GĐ2 không có báo-giá / so-sánh
// NCC (đã chốt ở GĐ1) mà có căn-cứ hồ-sơ TVGS. Giống nhau ở KHUNG, không ở chữ.
//
// [P2-7] `readOnly` là prop TỪ HOST — host quyết `phiếu ≠ Nháp user ≠ người soạn`.
// 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus,
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
function formatSize(b: number): string {
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
}
/** Khung Section ĐÁNH SỐ — mirror khuôn Section của màn Duyệt NCC (đánh số + divide-y). */
function Section({
title, icon, accent, head, chipBg, chipFg, count, actions, children,
}: {
title: string
icon: ReactNode
accent: string
head: string
chipBg: string
chipFg: string
count?: number
actions?: ReactNode
children: ReactNode
}) {
return (
<section className="px-5 py-4">
<header className="mb-3 flex items-center gap-2">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
aria-hidden
>
{icon}
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', head)} style={{ ['--accent' as string]: accent }}>
{title}
</h3>
{count != null && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{count}
</span>
)}
{actions && <span className="ml-auto">{actions}</span>}
</header>
{children}
</section>
)
}
function Field({
label, value, mono, full, children,
}: {
label: string
value?: ReactNode
mono?: boolean
full?: boolean
children?: ReactNode
}) {
const empty = value == null || value === ''
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')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
)
}
const EMPTY_ITEM: UpsertKhkkDossierItemInput = {
kind: DossierItemKind.MauVatLieu,
name: '',
status: DossierItemStatus.ChuaNop,
tvgsName: '',
note: '',
}
export function KhkkDetailContent({
plan,
readOnly,
onChanged,
}: {
plan: KhkkDetailDto
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
readOnly: boolean
onChanged?: () => void
}) {
const qc = useQueryClient()
const id = plan.id
const [editHeader, setEditHeader] = useState(false)
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
const phase = plan.phase as KhkkPhaseValue
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
// Sửa được = phiếu còn ở Nháp/Trả lại VÀ host không khoá (readOnly).
const isDraft = draftPhase && !readOnly
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['khkk-detail', id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
onChanged?.()
}
const saveHeader = useMutation({
mutationFn: async () => {
await api.put(`/contract-signing-plans/${id}`, {
ghiChu: ghiChu.trim() || null,
hoSoLink: hoSoLink.trim() || null,
})
},
onSuccess: () => { toast.success('Đã lưu'); setEditHeader(false); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({
queryKey: ['contract-catalog', plan.approvalGroup],
queryFn: async () =>
(await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
params: { approvalGroup: plan.approvalGroup },
})).data,
enabled: isDraft && plan.approvalGroup != null,
})
const assignLine = useMutation({
mutationFn: async (p: { lineId: string; catalogEntryId: string }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }),
onSuccess: () => { toast.success('Đã gán hạng mục'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
const payload = {
...body,
contractSigningPlanId: id,
name: body.name.trim(),
tvgsName: body.tvgsName?.trim() || null,
note: body.note?.trim() || null,
}
// [F-4 S161] POST ép Id=null = LUÔN insert ⇒ "Sửa" phải đi PUT, không thì đẻ bản sao im lặng.
if (body.id) await api.put(`/contract-signing-plans/${id}/dossier-items/${body.id}`, payload)
else await api.post(`/contract-signing-plans/${id}/dossier-items`, payload)
},
onSuccess: () => { toast.success('Đã lưu căn cứ'); setItemForm(null); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteItem = useMutation({
mutationFn: async (itemId: string) => api.delete(`/contract-signing-plans/${id}/dossier-items/${itemId}`),
onSuccess: () => { toast.success('Đã xóa căn cứ'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => { toast.success('Đã tải lên'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteAttachment = useMutation({
mutationFn: async (attId: string) => api.delete(`/contract-signing-plans/${id}/attachments/${attId}`),
onSuccess: () => { toast.success('Đã xóa file'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
async function download(att: KhkkAttachmentDto) {
try {
const res = await api.get(`/contract-signing-plans/${id}/attachments/${att.id}/download`, {
responseType: 'blob',
})
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
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 */ }
}
}
const startEditHeader = () => {
setGhiChu(plan.ghiChu ?? '')
setHoSoLink(plan.hoSoLink ?? '')
setEditHeader(true)
}
// 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)
// ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ──────────────────
const opinions = plan.levelOpinions ?? []
const steps = plan.workflowSteps ?? []
return (
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
{/* Dải 4 giai-đoạn — GĐ2 đang sáng. Cùng component với màn Duyệt NCC (không nhân bản). */}
<PePipelineStrip current={2} />
<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>
{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"
title={KHKK_APPROVAL_GROUP_LABELS[plan.approvalGroup] ?? `Nhóm ${plan.approvalGroup}`}
>
N{plan.approvalGroup}
</span>
)}
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', KHKK_PHASE_BADGE[phase])}>
{KHKK_PHASE_LABELS[phase]}
</span>
</div>
<div className="divide-y divide-slate-200">
{/* ─────────────── 1 ─────────────── */}
<Section
title="1. Thông tin kế hoạch"
icon={<FileCheck className="h-4 w-4" />}
accent="var(--color-brand-500)"
head="text-brand-800"
chipBg="var(--color-brand-50)"
chipFg="var(--color-brand-600)"
actions={
isDraft && !editHeader ? (
<Button variant="outline" size="sm" onClick={startEditHeader}>
<Pencil className="mr-2 h-3.5 w-3.5" /> Sửa
</Button>
) : undefined
}
>
<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>
)}
</Field>
<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 ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
<div>
<Label htmlFor="hoSoLink">Link hồ (NAS)</Label>
<Input id="hoSoLink" value={hoSoLink} onChange={(e) => setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />
</div>
<div>
<Label htmlFor="ghiChu">Ghi chú</Label>
<Textarea id="ghiChu" value={ghiChu} onChange={(e) => setGhiChu(e.target.value)} maxLength={2000} rows={4} />
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>Huỷ</Button>
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
<Save className="mr-2 h-3.5 w-3.5" />
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Link hồ sơ" full>
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300"></span>}
</Field>
<Field label="Ghi chú" full>
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300"></span>}
</Field>
</div>
)}
</Section>
{/* ─────────────── 2 ─────────────── */}
<Section
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`}
icon={<Users className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
actions={
plan.budgetFrozen ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
🔒 Giá đã chốt tại thời điểm duyệt
</span>
) : undefined
}
>
{plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
</tr>
</thead>
<tbody>
{plan.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'}
{l.contractId && (
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
</a>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{isDraft && !l.contractId ? (
<>
<select
value={l.catalogEntryId ?? ''}
onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
disabled={assignLine.isPending}
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
không thì dòng đã gán TRÔNG như chưa gán. */}
{l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
)}
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
{catalogOptions.isError && (
<div className="mt-1 text-[11px] text-red-600">
Không tải đưc danh mục hạng mục {' '}
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>thử lại</button>
</div>
)}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
<div className="mt-1 text-[11px] text-amber-700">
Nhóm duyệt N{plan.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục.
</div>
)}
</>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td>
<td className="px-3 py-2 text-xs text-slate-600">{l.note ?? '—'}</td>
</tr>
))}
</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.
{' '}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.
</div>
</Section>
{/* ─────────────── 3 ─────────────── */}
<Section
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
icon={<ClipboardList className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={plan.dossierItems.length}
actions={
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>
) : undefined
}
>
{plan.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
</tr>
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 text-xs text-slate-600">{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">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{d.tvgsName ?? '—'}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td>
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => setItemForm({
id: d.id, kind: d.kind, supplierId: d.supplierId, name: d.name,
status: d.status, tvgsName: d.tvgsName ?? '', note: d.note ?? '',
})}
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id) }}
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* ─────────────── 4 ─────────────── */}
<Section
title="4. File đính kèm"
icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={planAttachments.length}
actions={
readOnly ? undefined : (
<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={onPickFiles} />
</label>
)
}
>
{planAttachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : (
<ul className="space-y-2">
{planAttachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button>
{!readOnly && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
className="text-xs text-red-600 hover:underline"
>
Xóa
</button>
)}
</div>
</li>
))}
</ul>
)}
</Section>
{/* ─────────────── 5 — Ảnh 3 khoanh đỏ ───────────────
Gom theo BƯỚC (kèm PHÒNG) → lưới 2 cột các ô ý-kiến của từng CẤP, đúng khuôn
Section "Ý kiến cấp duyệt" V2 của Duyệt NCC (`PeDetailTabs.tsx:705-774`):
forEach Bước → forEach Cấp → 1 ô. Ô chưa ký vẫn HIỆN (chỗ trống có tên người) —
vắng ô là người đọc không biết còn thiếu chữ ký của ai. */}
<Section
title="5. Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={opinions.length}
>
{steps.length === 0 ? (
<div className="text-sm text-muted-foreground">
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<div className="space-y-4">
{steps.map((step) => {
const signedCount = step.levels.filter(lv =>
opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length
return (
<div key={step.id} className="rounded-lg border border-slate-200">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2">
<span className="text-xs font-semibold text-slate-800">Bước {step.order} {step.name}</span>
{step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600">
{step.departmentName}
</span>
)}
<span className="ml-auto text-[10px] text-slate-500">
{signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
</span>
</div>
<div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">
{step.levels.length === 0 && (
<p className="text-[11px] italic text-slate-400">Bước này chưa cấu hình cấp duyệt.</p>
)}
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-md border px-2.5 py-2 text-[11px]',
signed ? 'border-emerald-200 bg-emerald-50/40' : 'border-dashed border-slate-200 bg-white',
)}
>
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{lv.name || `Cấp ${lv.order}`}</span>
<span className="text-slate-400">·</span>
<span>{lv.approverFullName ?? '(chưa cấu hình)'}</span>
{signed && (
<>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(signed.signedAt)}</span>
{signed.approverUserId && signed.approverUserId !== signed.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay ({signed.signedByFullName})
</span>
)}
</>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{signed
? (signed.comment?.trim() || '(duyệt — không ý kiến)')
: <span className="italic text-slate-300">Chưa </span>}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)}
</Section>
</div>
{/* Dialog thêm/sửa căn cứ */}
<Dialog open={!!itemForm} onClose={() => setItemForm(null)} title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}>
{itemForm && (
<div className="space-y-3">
<div>
<Label htmlFor="item-kind">Loại căn cứ</Label>
<select
id="item-kind"
value={itemForm.kind}
onChange={(e) => setItemForm({ ...itemForm, kind: Number(e.target.value) as DossierItemKindValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_KIND_LABELS).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-name">Tên căn cứ <span className="text-red-500">*</span></Label>
<Input id="item-name" value={itemForm.name} onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })} maxLength={300} placeholder="vd. Mẫu thép D10 Hoà Phát" />
</div>
<div>
<Label htmlFor="item-status">Trạng thái</Label>
<select
id="item-status"
value={itemForm.status}
onChange={(e) => setItemForm({ ...itemForm, status: Number(e.target.value) as DossierItemStatusValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_STATUS_LABELS).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-tvgs">Tên TVGS xử </Label>
<Input id="item-tvgs" value={itemForm.tvgsName ?? ''} onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })} maxLength={200} />
</div>
<div>
<Label htmlFor="item-note">Ghi chú</Label>
<Textarea id="item-note" value={itemForm.note ?? ''} onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })} maxLength={1000} rows={3} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setItemForm(null)}>Huỷ</Button>
<Button onClick={() => upsertItem.mutate(itemForm)} disabled={upsertItem.isPending || !itemForm.name.trim()}>
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
)}
</Dialog>
</div>
)
}

View File

@ -0,0 +1,388 @@
// [S171 KHKK 3-panel] PANEL 1 của màn "Kế hoạch ký kết HĐ" (GĐ2) — mirror bố-cục
// Duyệt NCC (GĐ1) / Hợp đồng (GĐ3): khối filter trên cùng + thân danh sách cuộn riêng.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 KHỐI Ý-KIẾN / SƠ-ĐỒ DUYỆT là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774`
// · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được khi và chỉ khi BE 3 module
// thống-nhất shape `status` precompute. Trước đó, gộp = đẻ `if (module === …)`.
//
// ══ OWNER CHỐT 2026-08-04 (QĐ-1): panel 1 = CÂY TOÀN TRÌNH `PipelineTreePanel currentStage={2}`.
// Chọn HÌNH ≠ chấp-nhận VỠ ⇒ 4 mitigation dưới đây là BẮT BUỘC, không phải tuỳ chọn:
// M-1 `canPe` (`usePipelineStages.ts:83`) khoá cây bằng 3 key của Duyệt NCC. Người CHỈ có
// quyền KHKK (vd người duyệt nhóm N5) sẽ thấy cây trống trong khi API
// `GET /contract-signing-plans` (policy `KeHoachKyKet.Read`) VẪN cho họ đọc — đúng mặt
// còn lại của gotcha #82. ⇒ `!canPe` FALLBACK sang DANH-SÁCH PHẲNG cùng ô này.
// Cây là lớp LÀM GIÀU, KHÔNG phải điều-kiện để thấy phiếu.
// M-2 Leaf/card KHKK trên cây KHÔNG `navigate('/khkk/:id')` nữa mà gọi `onSelect` của host
// (`usePipelineStages.ts` `onOpenKhkk`) — không thì bấm 1 cái là văng khỏi màn 3-panel.
// B1 Truyền `stage2Content` PHẢI kèm `stage2Count` (`PipelineStageFolders.tsx:95-97`).
// Trade-off khai thẳng: trong cây của trang này, folder GĐ2 hiện CARD PHIẾU thay cho
// 8 ngăn N1-N8; lối vào nhóm giữ ở `Select` nhóm ngay bên trên.
// R-11 Cây chỉ tải 200 phiếu PE mới nhất (`PipelineTreePanel.tsx:22`) và KHÔNG có "trang 2".
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
// (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 { FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
} from '@/types/khkk'
const PAGE_SIZE = 20
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
/**
* [F-2] Chip "⚑ Kết thúc · {Bước}" — khuôn `components/pe/PeFinalizeChip.tsx` (S89/S97),
* dựng lại ở đây thay vì import vì nguồn cờ khác module (KHKK đọc
* `endedByLevelFinalize` + `finalizeStepName`, PE đọc `endsBeforeCeo`).
*/
export function KhkkFinalizeChip({
endedByLevelFinalize,
finalizeStepName,
phase,
}: {
endedByLevelFinalize: boolean
finalizeStepName: string | null
phase?: number
}) {
if (!endedByLevelFinalize) {
if (phase === KhkkPhase.DaDuyet) {
return (
<span
className="inline-flex shrink-0 items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800"
title="Kế hoạch đi HẾT quy trình (không kết thúc sớm ở cấp dưới)"
>
Duyệt hết quy trình
</span>
)
}
return null
}
return (
<span
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình tiếp cấp sau`}
>
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Kết thúc sớm'}
</span>
)
}
/**
* Card 3 hàng — thay cho bảng 8 cột + 6 KpiCard của bản cũ (bảng "cột nào chết, chuyển
* đi đâu" nằm ở `sub-implementer-frontend-s171.md` §4).
* [M-8] Σ tiền in MỘT số, suy từ `phase === DaDuyet` (đúng như BE suy `budgetFrozen`) —
* KHÔNG thêm field vào `KhkkListItemDto`, vì `budgetFrozen` chỉ có ở DetailDto.
*/
export function KhkkPlanCard({
plan,
selected,
onSelect,
finalize,
}: {
plan: KhkkListItemDto
selected: boolean
onSelect: (id: string) => void
/** Chỉ có khi phiếu ĐANG được chọn (detail đã tải) — list payload không mang cờ này. */
finalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const frozen = plan.phase === KhkkPhase.DaDuyet
const amount = frozen ? (plan.totalApprovedAmount ?? plan.totalProposedAmount) : plan.totalProposedAmount
return (
<button
type="button"
onClick={() => onSelect(plan.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
selected && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1">
{finalize && (
<KhkkFinalizeChip
endedByLevelFinalize={finalize.endedByLevelFinalize}
finalizeStepName={finalize.finalizeStepName}
phase={plan.phase}
/>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">
{plan.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</span>
</div>
</div>
<span
className={cn(
'shrink-0 rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
KHKK_PHASE_BADGE[plan.phase],
)}
>
{KHKK_PHASE_LABELS[plan.phase]}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
{plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{plan.approvalGroup}
</span>
)}
<span>·</span>
<span className="truncate">{formatDateTime(plan.createdAt)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] text-slate-500">
<span className="min-w-0 truncate">👤 {plan.drafterFullName ?? '—'}</span>
<span className={cn('shrink-0 tabular-nums', frozen ? 'text-emerald-700' : 'text-brand-800')}>
{formatVnd(amount)}
</span>
</div>
</button>
)
}
export function KhkkListPanel({
selectedId,
onSelect,
search,
onSearchChange,
phase,
onPhaseChange,
group,
onGroupChange,
deletedView,
page,
onPageChange,
selectedFinalize,
}: {
selectedId: string | null
onSelect: (id: string) => void
search: string
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const { canPe } = usePipelineStages()
const term = search.trim()
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm
// ngoài 200 phiếu mới nhất VẪN tới được.
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm
// người dùng mất trắng phiếu của chính họ.
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null
const list = useQuery({
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }],
queryFn: async () => {
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
group: group ?? undefined,
search: term || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
enabled: flatMode,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
// [B1] Thân folder GĐ2 của cây = CARD phiếu của chính trang này; `Select` nhóm lọc luôn
// trên cây (client-side, an toàn: đây chỉ là chia lại đúng tập phiếu cây đã có).
const stage2Content = (plans: KhkkListItemDto[]) => {
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) {
return (
<p className="px-3 py-2 text-[11px] italic text-slate-400">
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
</p>
)
}
return (
<ul className="divide-y divide-slate-100">
{shown.map(k => (
<li key={k.id}>
<KhkkPlanCard
plan={k}
selected={selectedId === k.id}
onSelect={onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined}
/>
</li>
))}
</ul>
)
}
const stage2Count = (plans: KhkkListItemDto[]) =>
(group === null ? plans : plans.filter(k => k.approvalGroup === group)).length
return (
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
{/* Khối filter — CHỖ ĐẶT giống PE/GĐ3 (trên cùng panel 1). `Select` nhóm N1-N8 là
chỗ KHKK khác nguồn dữ-liệu chứ không khác bố-trí (P1-4). */}
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => onSearchChange(e.target.value)}
placeholder="Tìm mã kế hoạch / mã phiếu / gói thầu…"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
{flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400">
Đ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>
)}
{!canPe && (
<p className="text-[10px] italic text-slate-400">
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
sách kế hoạch kết vẫn liệt đy đ dưới.
</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!flatMode && (
<PipelineTreePanel
currentStage={2}
className="border-0 shadow-none"
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>
)}
{flatMode && list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100 motion-reduce:animate-none" />
))}
</div>
)}
{flatMode && list.isError && (
<div className="space-y-2 px-3 py-4">
<p className="text-[12px] text-slate-500">Không tải đưc danh sách kế hoạch kết.</p>
<button
type="button"
onClick={() => list.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
Thử lại
</button>
</div>
)}
{flatMode && !list.isLoading && !list.isError && items.length === 0 && (
<div className="p-6 text-center text-sm text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào khớp bộ lọc.'}
</div>
)}
{flatMode && items.length > 0 && (
<ul className="divide-y divide-slate-100">
{items.map(k => (
<li key={k.id} className={cn(deletedView && 'opacity-70')}>
<KhkkPlanCard
plan={k}
selected={selectedId === k.id}
onSelect={deletedView ? () => { /* phiếu đã xóa: chỉ liệt kê, không mở */ } : onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined}
/>
</li>
))}
</ul>
)}
</div>
{flatMode && totalPages > 1 && (
<div className="flex shrink-0 items-center justify-between border-t border-slate-200 px-3 py-2 text-[11px]">
<span className="text-slate-500">
<FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
</button>
</div>
</div>
)}
</aside>
)
}

View File

@ -0,0 +1,726 @@
// [S171 KHKK 3-panel] PANEL 3 (phải) — quy trình duyệt + lịch sử của "Kế hoạch ký kết HĐ".
// DỜI từ thư mục trang KHKK (file cùng tên) sang `components/khkk/` (cùng lớp với
// `components/pe/PeWorkflowPanel.tsx` của Duyệt NCC — panel dùng ở NHIỀU host thì không
// thuộc về một trang). File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 Khối này là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774` · HĐ
// `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được KHI VÀ CHỈ KHI BE 3 module
// thống-nhất shape `status` precompute — trước đó gộp chỉ đẻ `if (module === …)`.
//
// 7 khối theo bố-cục đích (mirror Ảnh 2 của Duyệt NCC):
// 1 "Quy trình duyệt" + meta {code} v{NN} · {name}
// 2 Banner kết-thúc-sớm ("không qua CEO")
// 3 Sơ đồ Bước → Cấp → NV (chip phòng · ⚑ Duyệt thay CEO · mờ nhánh không chạy · liệt-kê NV)
// 4 Nút Duyệt / Trả lại / Từ chối + Dialog + nút Xóa phiếu
// 5 📎 File đính kèm khi duyệt (upload THẬT — owner chốt QĐ-3)
// 6 Lịch sử duyệt (N)
// 7 Lịch sử thay đổi
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do
// BE precompute. KHKK BE trả CÂY THÔ — panel này TỰ SUY từ cặp con-trỏ:
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order)
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
// Luật so khớp OR-of-N canonical (BE `ContractSigningPlanFeatures.cs:259-260`):
// `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror bằng
// `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.
//
// [R-8] KHKK KHÔNG có phiếu V1-legacy ⇒ KHÔNG port nhánh fallback V1 của PE (code chết).
// [ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH] KHÔNG price-picker (KHKK chốt giá per-Line ở choke-point BE
// `ApplyApprovedValuesOnFinalize`), KHÔNG badge "✎ NS PRO/CCM" (khái niệm riêng PE, Mig 50),
// và đợt này giữ 1 return-mode (về Bước 1 · Cấp 1) — 4 mode của PE là món riêng, còn treo.
// 🔴 [F-1 S166 · RATIFIED @S167] Ô-tích "Cấp này KẾT THÚC" = OPT-IN theo khuôn PE-LIVE S97
// (`PeWorkflowPanel.tsx:61` useState(false)). Default KHÔNG tick ⇒ trình tiếp cấp sau.
import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import {
KHKK_CHANGELOG_ACTION_LABELS, KHKK_CHANGELOG_ENTITY_TYPE_LABELS,
KHKK_PHASE_LABELS, KhkkApprovalDecision, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction,
type KhkkApprovalDto, type KhkkAttachmentDto, type KhkkChangelogDto, type KhkkDetailDto,
type KhkkPhaseValue, type KhkkTransitionActionValue, type KhkkTransitionInput,
type KhkkTransitionResult, type KhkkWorkflowLevelDto,
} from '@/types/khkk'
type NodeStatus = 'Done' | 'Current' | 'Pending'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
function formatSize(b: number): string {
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
}
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
const STATUS_DOT: Record<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
Done: 'border-emerald-200 bg-emerald-50/40',
Current: 'border-brand-300 bg-brand-50/50',
Pending: 'border-slate-200 bg-white',
}
const STATUS_ICON: Record<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
const DECISION_LABEL: Record<number, string> = {
0: 'Gửi duyệt',
1: 'Duyệt',
2: 'Trả lại / Từ chối',
3: 'Tự động duyệt',
}
export function KhkkWorkflowPanel({
plan,
onChanged,
onDeleted,
}: {
plan: KhkkDetailDto
/** gọi sau khi transition thành công — caller refetch detail/list theo id của nó. */
onChanged?: () => void
/** gọi sau khi XÓA phiếu — host 3-panel bỏ chọn, trang chi tiết quay về danh sách. */
onDeleted?: () => void
}) {
const qc = useQueryClient()
const { user } = useAuth()
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
const [comment, setComment] = useState('')
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
// [QĐ-3 / BE-7] File người duyệt đính kèm ngay trong dialog duyệt.
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? []
const approvals = plan.approvals ?? []
const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder
// Chữ ký thật: tra theo `approvalWorkflowLevelId` của các hàng ĐÃ DUYỆT trong lịch sử duyệt
// (BE đã lọc `Pending`). Dùng nguồn này thay vì ý-kiến vì ý-kiến sống ở panel 2 (D-3).
const signedByLevelId = useMemo(() => {
const m = new Map<string, KhkkApprovalDto>()
for (const a of approvals) {
if (a.approvalWorkflowLevelId && a.decision !== KhkkApprovalDecision.Reject) {
if (!m.has(a.approvalWorkflowLevelId)) m.set(a.approvalWorkflowLevelId, a)
}
}
return m
}, [approvals])
const stepStatus = (idx: number): NodeStatus => {
if (isApproved) return 'Done'
if (!isWaiting || curStepIdx == null) return 'Pending'
if (idx < curStepIdx) return 'Done'
if (idx === curStepIdx) return 'Current'
return 'Pending'
}
const levelStatus = (idx: number, order: number): NodeStatus => {
const s = stepStatus(idx)
if (s !== 'Current' || curLevelOrder == null) return s
if (order < curLevelOrder) return 'Done'
if (order === curLevelOrder) return 'Current'
return 'Pending'
}
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này.
const currentLevels: KhkkWorkflowLevelDto[] =
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
: []
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
? steps[curStepIdx]
: null
const isAdmin = user?.roles?.includes('Admin') ?? false
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
// Ô-tích hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel`
// (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC, Admin không có own
// ⇒ level ĐẦU nhóm cùng-order. Soi cờ của level KHÁC là hiện ô-tích nói dối.
const actingLevel = currentLevels.find(l => l.approverUserId === user?.id)
?? (isAdmin ? currentLevels[0] : undefined)
const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true
// Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy DeptManager Admin`). FE KHÔNG suy được
// vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im
// lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
// [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu).
const [showChangelog, setShowChangelog] = useState(false)
const changelogs = useQuery({
queryKey: ['khkk-changelogs', plan.id],
queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 },
})).data,
enabled: showChangelog,
})
const transition = useMutation({
mutationFn: async (a: KhkkTransitionActionValue) => {
const body: KhkkTransitionInput = {
action: a,
comment: comment.trim() || null,
...(a === KhkkTransitionAction.Approve
? { applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true }
: {}),
}
const res = await api.post<KhkkTransitionResult>(`/contract-signing-plans/${plan.id}/transitions`, body)
// [QĐ-3] File đính kèm khi duyệt — upload SAU khi transition thành công (transition
// hỏng thì không đẻ file mồ côi). BE tự ép `purpose=ApprovalAttachment`, FE KHÔNG gửi
// số enum lên (hợp-đồng lane BE §5).
for (const f of pendingFiles) {
const fd = new FormData()
fd.append('file', f)
await api.post(`/contract-signing-plans/${plan.id}/approval-attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
return res.data
},
onSuccess: (res) => {
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
setAction(null)
setComment('')
setPendingFiles([])
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
qc.invalidateQueries({ queryKey: ['khkk-changelogs', plan.id] })
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
onChanged?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const removePlan = useMutation({
mutationFn: async () => api.delete(`/contract-signing-plans/${plan.id}`),
onSuccess: () => {
toast.success('Đã xóa kế hoạch')
setConfirmDelete(false)
qc.invalidateQueries({ queryKey: ['khkk-list'] })
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
onDeleted?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const dialogTitle =
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
: '✗ Từ chối kế hoạch ký kết'
const commentRequired =
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowVersion != null && ` v${plan.workflowVersion}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
)}
</header>
<div className="space-y-4 p-5">
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{plan.endedByLevelFinalize && (
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
<div className="font-medium">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'}
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''}
</div>
<div className="mt-0.5">
{isApproved
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.'
: 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'}
</div>
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
// Gom Cấp CÙNG `order` thành MỘT dòng — OR-of-N: N người, chỉ 1 chữ ký là tiến.
const orders = [...new Set(step.levels.map(l => l.order))].sort((a, b) => a - b)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex flex-wrap items-center gap-1.5 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* Chip PHÒNG của Bước (BE-4 join `Departments`). */}
{step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600">
{step.departmentName}
</span>
)}
</div>
{orders.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{orders.map(order => {
const group = step.levels.filter(l => l.order === order)
const signedRows = group.map(l => signedByLevelId.get(l.id)).filter(Boolean) as KhkkApprovalDto[]
const hasSigned = signedRows.length > 0
const finalizeHere = group.some(l => l.allowApproverFinalize)
// Nhánh KHÔNG CHẠY: phiếu đã duyệt bằng đường kết-thúc-sớm ⇒ các cấp
// sau đó không ai ký. Vẽ MỜ + trạng thái ○ thay vì bịa ✓ cho cả cây.
const skipped = isApproved && plan.endedByLevelFinalize && !hasSigned
const ls: NodeStatus = skipped ? 'Pending' : levelStatus(idx, order)
return (
<li key={`${step.id}-${order}`} className={cn('text-[11px]', skipped && 'opacity-50')}>
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
{skipped && <span className="ml-1.5 text-[10px] font-normal text-slate-500">không phải qua</span>}
{finalizeHere && (
<span
className="ml-1.5 inline-flex items-center rounded bg-violet-100 px-1.5 py-0.5 text-[9px] font-semibold text-violet-800"
title="Cấp này duyệt là KẾT THÚC quy trình — duyệt THAY CEO, không trình lên Ban Giám đốc"
>
Duyệt thay CEO
</span>
)}
</div>
{/* [F-3] Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn
lại `/ Tên` mờ — mirror `PeWorkflowPanel.tsx:453-465`. */}
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
{group.length === 0
? '(chưa cấu hình)'
: group.map((l, i) => {
const sign = signedByLevelId.get(l.id)
return (
<span key={l.id} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">/</span>}
<span className={cn(sign ? 'font-semibold text-slate-800' : 'text-slate-400')}>
{l.approverFullName ?? '(chưa cấu hình)'}
</span>
{sign && (
<span className="text-[10px] text-emerald-600"> {formatDateTime(sign.approvedAt)}</span>
)}
</span>
)
})}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Quy trình duyệt chưa cấu hình Bước/Cấp liên hệ Admin đ dựng quy trình loại
&ldquo;Kế hoạch kết &rdquo; trước khi trình duyệt.
</div>
)}
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{phase === KhkkPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
Kế hoạch bị TRẢ LẠI sửa nội dung rồi bấm &ldquo;Gửi duyệt&rdquo;, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{phase === KhkkPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch đã bị TỪ CHỐI không thao tác đưc nữa. Lập kế hoạch mới nếu cần làm lại.
</div>
)}
{/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */}
{(canSubmit || isWaiting || canDeletePlan) && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && (
<>
<button
type="button"
disabled={blockedByLevel}
onClick={() => {
if (!blockedByLevel) {
setComment('')
setPendingFiles([])
// reset về default KHÔNG-tick (opt-IN, khuôn PE S97) mỗi lần mở dialog.
setApplyLevelFinalize(false)
setAction(KhkkTransitionAction.Approve)
}
}}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
<Check className="h-3 w-3" /> Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Reject) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-red-300 text-red-700 hover:bg-red-50',
)}
>
<X className="h-3 w-3" /> Từ chối
</button>
</>
)}
{canDeletePlan && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-bold text-slate-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</div>
</div>
)}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{approvalFiles.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Paperclip className="h-3.5 w-3.5 text-teal-500" />
File đính kèm khi duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvalFiles.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{approvalFiles.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="min-w-0">
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<a
href={`/api/contract-signing-plans/${plan.id}/attachments/${a.id}/download`}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
>
Tải xuống
</a>
</li>
))}
</ul>
</div>
)}
{/* ── 6. Lịch sử duyệt (N) ───────────────────────────────────────────── */}
{/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây
CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn
còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Check className="h-3.5 w-3.5 text-emerald-500" />
Lịch sử duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvals.length}
</span>
</div>
{approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : (
<ul className="mt-1.5 space-y-1.5">
{approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className={cn(
'rounded px-1.5 py-0.5 text-[9px] font-semibold',
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-0.5 text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
</span>
</div>
{a.comment?.trim() && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div>
)}
</li>
))}
</ul>
)}
</div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div>
<button
type="button"
onClick={() => setShowChangelog(v => !v)}
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
<History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span>
</button>
{showChangelog && (
<div className="mt-1.5">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && (
<p className="text-[11px] italic text-slate-400">Chưa thay đi nào đưc ghi.</p>
)}
<ul className="space-y-1">
{(changelogs.data ?? []).map(c => (
<li key={c.id} className="rounded border border-slate-100 bg-white px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-600">
<span className="font-medium text-slate-700">
{KHKK_CHANGELOG_ACTION_LABELS[c.action] ?? 'Thay đổi'}
</span>
<span className="text-slate-400">·</span>
<span>{KHKK_CHANGELOG_ENTITY_TYPE_LABELS[c.entityType] ?? 'Khác'}</span>
<span className="text-slate-400">·</span>
<span>{c.userName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(c.createdAt)}</span>
</div>
{(c.summary || c.contextNote) && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{c.summary}
{c.contextNote ? `${c.contextNote}` : ''}
</div>
)}
</li>
))}
</ul>
</div>
)}
</div>
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === KhkkTransitionAction.Submit && (
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
Kế hoạch chuyển sang &ldquo;Chờ duyệt&rdquo; trình lên Bước 1 · Cấp 1 của quy trình
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa đưc nữa.
</div>
)}
{action === KhkkTransitionAction.Return && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Kế hoạch về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
{action === KhkkTransitionAction.Reject && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
</div>
)}
{action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input type="checkbox" className="mt-0.5" checked={applyLevelFinalize} onChange={e => setApplyLevelFinalize(e.target.checked)} />
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Đã tích — duyệt xong là kế hoạch "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
: 'Mặc định: duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này.'}
</span>
</span>
</label>
</div>
)}
<Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
/>
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && (
<div className="mt-3">
<Label className="text-xs">File đính kèm (tuỳ chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => {
setPendingFiles(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-50"
>
<Paperclip className="h-3 w-3" /> Chọn file
</button>
{pendingFiles.length === 0
? <span className="text-[11px] text-slate-400">Chưa chọn file nào</span>
: (
<span className="text-[11px] text-slate-600">
{pendingFiles.map(f => f.name).join(', ')}
<button type="button" onClick={() => setPendingFiles([])} className="ml-2 text-red-600 underline">bỏ chọn</button>
</span>
)}
</div>
<p className="mt-1 text-[11px] text-slate-500">
File đưc tải lên SAU khi thao tác duyệt thành công hiện mục &ldquo;File đính kèm khi duyệt&rdquo;.
</p>
</div>
)}
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ).
</p>
)}
</Dialog>
)}
{confirmDelete && (
<Dialog
open
onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết"
footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Huỷ</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button>
</>}
>
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
Phiếu <span className="font-mono font-semibold">{plan.maKeHoach ?? '(chưa cấp mã)'}</span> sẽ bị
xóa mềm: biến khỏi danh sách khỏi cây toàn trình, chỉ còn xem đưc mục &ldquo;Đã xóa&rdquo;.
</div>
</Dialog>
)}
</section>
)
}

View File

@ -13,7 +13,7 @@ import { useQuery } from '@tanstack/react-query'
import { ListTree, Search } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { usePipelineStages } from '@/hooks/usePipelineStages'
import { usePipelineStages, type BuildStagesOptions } from '@/hooks/usePipelineStages'
import { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders'
import type { Paged } from '@/types/master'
import type { PeListItem } from '@/types/purchaseEvaluation'
@ -87,9 +87,14 @@ export function PipelineTreePanel({
currentStage,
className,
bodyMaxHClass,
stage2Options,
}: {
currentStage: PipelineStageNo
className?: string
// [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt
// sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk'>
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
@ -306,7 +311,7 @@ export function PipelineTreePanel({
className="ml-3 border-l border-slate-200"
currentStage={currentStage}
hideEmpty={hideEmpty}
stages={buildStages(wg.items)}
stages={buildStages(wg.items, stage2Options ?? {})}
/>
</details>
)

View File

@ -162,6 +162,27 @@ export type BuildStagesOptions = {
/** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
stage1Content?: ReactNode
stage1Count?: number
// ── [S171 — mitigation B1 + M-2, owner chốt panel 1 KHKK = cây] ─────────────
/**
* GĐ2 tự vẽ (trang Kế hoạch ký kết truyền khối CARD phiếu của chính nó).
* 🔴 TRADE-OFF ĐÃ KHAI: `PipelineStageFolders.tsx:285-287` đặt `content` TRƯỚC `groups`
* trong chuỗi tam-nguyên ⇒ truyền cái này thì 8 ngăn N1-N8 KHÔNG vẽ nữa (im lặng, không
* lỗi). Chấp nhận được VÌ lối vào nhóm giữ ở `Select` nhóm của khối filter panel 1.
* ADDITIVE: `PurchaseEvaluationsListPage` không truyền ⇒ trang PE/GĐ3 GIỮ NGUYÊN 8 ngăn.
*/
stage2Content?: (items: KhkkListItemDto[]) => ReactNode
/**
* 🔴 BẮT BUỘC đi kèm `stage2Content` — `stageCount()` (`PipelineStageFolders.tsx:95-97`)
* đọc `count ?? 0` khi có `content`, thiếu là badge về 0 trong khi folder đầy phiếu.
* Mặc định (không truyền) = số phiếu của gói, KHÔNG bao giờ để rơi về 0.
*/
stage2Count?: (items: KhkkListItemDto[]) => number
/**
* [M-2] Bấm leaf/card KHKK ⇒ HOST xử lý (chọn vào panel 2 của màn 3-panel) thay vì
* `navigate('/khkk/:id')` rời trang. KHÔNG truyền ⇒ giữ NGUYÊN hành vi cũ (navigate)
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
*/
onOpenKhkk?: (id: string) => void
}
/**
@ -208,7 +229,8 @@ export function usePipelineStages() {
secondary: k.peTenGoiThau,
badgeLabel: KHKK_PHASE_LABELS[k.phase],
badgeClass: KHKK_PHASE_BADGE[k.phase],
onOpen: () => navigate(`/khkk/${k.id}`),
// [S171 M-2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
onOpen: opts.onOpenKhkk ? () => opts.onOpenKhkk!(k.id) : () => navigate(`/khkk/${k.id}`),
})
const khkkLeaves = khkkItems.map(toKhkkLeaf)
@ -265,7 +287,14 @@ export function usePipelineStages() {
const stage2: PipelineStage = {
n: 2,
leaves: khkkLeaves,
groups: khkkGroups,
// [S171 B1] `content` ⇒ thân folder do host vẽ (card phiếu KHKK) và `groups` bị bỏ
// qua; `count` PHẢI đi cùng, mặc định = số phiếu của gói (không để rơi về 0).
...(opts.stage2Content !== undefined
? {
content: opts.stage2Content(khkkItems),
count: (opts.stage2Count ?? (its => its.length))(khkkItems),
}
: { groups: khkkGroups }),
locked: !linkage.canKhkk,
failed: linkage.khkkFailed,
loading: linkage.khkkLoading,

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,47 @@
// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2).
// Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
// fe-admin counterpart.
// [S171] HOST 3-PANEL của màn "Kế hoạch ký kết HĐ" (GĐ2) — đưa GĐ2 về CÙNG bố-cục với
// Duyệt NCC (GĐ1, `pages/pe/PurchaseEvaluationsListPage.tsx:381,397`) và Hợp đồng
// (GĐ3, `pages/contracts/ContractsListPage.tsx:106,125`). Trước S171 trang này là
// `space-y-5` cuộn-body 2 panel ⇒ mắt-xích lệch DUY NHẤT trong 4 giai-đoạn.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// URL vào từ menu (Layout.tsx — staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm
// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
// Khuôn code lấy từ `ContractsListPage.tsx` (255 dòng, gọn) — CỐ Ý KHÔNG chép
// `PurchaseEvaluationsListPage.tsx` (807 dòng: kéo theo `deleted`/`pendingMe`/`awId`/overlay).
// Số đo panel lấy của PE (400px) vì hiến-pháp owner là "giống nhất với Duyệt NCC".
//
// URL vào từ menu (Layout.tsx — staticMap nhóm 1, regex `Khkk_G{n}_*` nhóm 2..8):
// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
// 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng
// nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc).
//
// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5).
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
import { useMemo, useState, type ReactNode } from 'react'
// 🔴 [M-4] KHOÁ URL ĐƯỢC PHÉP GHI = `id` (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`).
// TUYỆT ĐỐI KHÔNG ghi `filter` / `view` / `group` vào `setSearchParams`: đứng leaf
// `?group=1` mà ghi thêm `filter=DaDuyet` là URL khớp CHÍNH XÁC leaf "Đã duyệt" của nhóm ⇒
// sidebar sáng nhầm mục — đúng bug UAT S155. Bộ lọc người dùng bấm sống ở LỚP OVERRIDE
// (state) như bản cũ, không ghi ngược URL.
//
// 🔴 [K4b S166 · R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER. Khối reset dưới đây là SET-STATE-TRONG-RENDER **CÓ CHỦ ĐÍCH** (khuôn React
// "reset state khi input đổi") — ai refactor thành `useEffect` sẽ tái-hiện vá-5 (đi từ
// leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Plus, Search, FileCheck, FileEdit, SendHorizontal,
CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { FileCheck, Plus, X } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUP_LABELS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkDetailDto,
type KhkkPhaseValue,
type PagedResult,
} from '@/types/khkk'
const PAGE_SIZE = 20
// Tên phase trong query `?filter=` → int (URL đọc được, không dính số ma thuật).
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
DangSoanThao: KhkkPhase.DangSoanThao,
@ -53,308 +51,156 @@ const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
TuChoi: KhkkPhase.TuChoi,
}
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function KhkkListPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const { user } = useAuth()
// ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ----
// Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi
// query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại.
// Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5).
// ---- 3 trục DANH TÍNH đọc thẳng từ URL (chỉ ĐỌC — không bao giờ ghi lại, xem M-4) ----
const urlFilter = searchParams.get('filter')
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
const deletedView = searchParams.get('view') === 'deleted'
// `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1,
// vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh).
const groupParam = searchParams.get('group')
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const urlGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const selectedId = searchParams.get('id')
// ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL;
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
// ---- Lớp override do NGƯỜI DÙNG bấm, đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa chạm ⇒ nghe theo URL; `null` = user
// chủ động chọn "Tất cả"; số = user chọn một giá trị. Gộp `undefined` với `null` thì chọn
// "Tất cả" sẽ bị URL kéo ngược về bộ lọc cũ.
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
const [groupOverride, setGroupOverride] = useState<number | null | undefined>(undefined)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
// Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đng trang 5 của nhóm 2
// mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
// vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm
// một mã rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
// Đổi leaf ⇒ trả bộ lọc về cho URL và về trang 1. `search` CỐ Ý giữ lại: đang tìm một mã
// rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${urlGroup ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
const [lastNavKey, setLastNavKey] = useState(navKey)
if (navKey !== lastNavKey) {
setLastNavKey(navKey)
setChipPhase(undefined)
setGroupOverride(undefined)
setPage(1)
}
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
const group: number | null = groupOverride !== undefined ? groupOverride : urlGroup
const list = useQuery({
// 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
// 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
// có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là
// `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server)
// ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô
// cache cho hai request y hệt nhau.
queryKey: ['khkk-list', { group, phase, search, page, deletedView }],
queryFn: async () => {
// Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng
// `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155).
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
// [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror
// List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery`
// + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server,
// total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa.
group: group ?? undefined,
search: search.trim() || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
const detail = useQuery({
queryKey: ['khkk-detail', selectedId],
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${selectedId}`)).data,
enabled: !!selectedId,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
function setId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('id')
else next.set('id', id)
setSearchParams(next)
}
// Presentation-only: đếm trên trang đang tải (không fetch thêm). Card đang active
// hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh.
const countByPhase = useMemo(() => {
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 }
for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1
return acc
}, [items])
function selectPlan(id: string) {
// `<lg` không có 3 panel ⇒ mở trang chi tiết đầy đủ (route `/khkk/:id` GIỮ NGUYÊN).
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setId(id)
} else {
navigate(group !== null ? `/khkk/${id}?group=${group}` : `/khkk/${id}`)
}
}
const phaseCards: Array<{
value: KhkkPhaseValue | null
label: string
icon: ReactNode
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
count: number
}> = [
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
{ value: KhkkPhase.DaDuyet, label: KHKK_PHASE_LABELS[3], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: phase === 3 ? total : countByPhase[3] },
{ value: KhkkPhase.TraLai, label: KHKK_PHASE_LABELS[98], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: phase === 98 ? total : countByPhase[98] },
{ value: KhkkPhase.TuChoi, label: KHKK_PHASE_LABELS[99], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: phase === 99 ? total : countByPhase[99] },
]
const plan = detail.data
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
const readOnly =
!plan
|| !(plan.phase === KhkkPhase.DangSoanThao || plan.phase === KhkkPhase.TraLai)
|| (!!user?.id && plan.drafterUserId !== user.id)
const headerTitle = deletedView
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null ? `Kế hoạch ký kết HĐ — N${group}` : 'Kế hoạch ký kết HĐ'
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title={
deletedView
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null
? `Kế hoạch ký kết HĐ — N${group}`
: 'Kế hoạch ký kết HĐ'
}
subtitle={
// [K4b S166] Màn "Đã xóa" nay ĐƯỢC in tên nhóm — `/deleted` đã lọc server-side
// theo `group` (FLAG-1 nối cùng lượt), tên nhóm hết là lời nói dối.
deletedView
? (group !== null ? `${khkkGroupMenuLabel(group)} — phiếu đã xóa mềm, chỉ xem` : 'Phiếu đã xóa mềm — chỉ xem danh sách')
: group !== null
? khkkGroupMenuLabel(group)
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
}
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
accent="brand"
actions={
deletedView ? undefined : (
// Mang nhóm đang xem sang màn lập phiếu — không bắt người dùng chọn lại
// đúng cái nhóm họ vừa đứng (bắt tay với vá-6 bên `KhkkCreatePage`).
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" />
<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">
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''}
</span>
</div>
{!deletedView && (
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
<Plus className="mr-2 h-4 w-4" />
Lập kế hoạch
</Button>
)
)}
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1 — cây toàn trình (owner QĐ-1) + 4 mitigation, xem KhkkListPanel.tsx */}
<KhkkListPanel
selectedId={selectedId}
onSelect={selectPlan}
search={search}
onSearchChange={(v) => { setSearch(v); setPage(1) }}
phase={phase}
onPhaseChange={(v) => { setChipPhase(v); setPage(1) }}
group={group}
onGroupChange={(v) => { setGroupOverride(v); setPage(1) }}
deletedView={deletedView}
page={page}
onPageChange={setPage}
selectedFinalize={
plan
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
: undefined
}
/>
{/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner
chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng
8 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
<PipelineTreePanel currentStage={2} />
<div className="min-w-0 space-y-5">
{!deletedView && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{phaseCards.map((c) => (
<KpiCard
key={c.value ?? 'all'}
label={c.label}
value={c.count}
icon={c.icon}
accent={c.accent}
active={phase === c.value}
onClick={() => {
// Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng
// để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file).
setChipPhase(c.value)
setPage(1)
}}
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState
icon={FileCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
description={deletedView
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không thao tác.'
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
/>
))}
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !plan && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{selectedId && plan && (
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} />
)}
</main>
{/* Panel 3 — quy trình duyệt + lịch sử.
🔴 OWNER CHỐT (QĐ-2): chưa chọn phiếu ⇒ CHỈ hộp dashed y hệt Duyệt NCC
(`PurchaseEvaluationsListPage.tsx:669-676`). KHÔNG nhét `PipelineTreePanel` vào
đây — cây đã sống ở panel 1 theo QĐ-1, nhét lần hai là trùng. Khác GĐ3 ở điểm
này là CÓ CHỦ ĐÍCH. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
{deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div>
)}
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã kế hoạch, mã phiếu hoặc tên gói thầu..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
{selectedId && plan && (
<KhkkWorkflowPanel
plan={plan}
onChanged={() => detail.refetch()}
onDeleted={() => setId(null)}
/>
</div>
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 8 cột cuộn ngang trong thẻ thay vì
tràn ra ngoài lưới trên laptop 1366. */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-4 py-2.5 text-left"> kế hoạch</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đ xuất</th>
{/* [K2 S164 — vá-8] Σ `ApprovedAmount` (`ListItemDto:64`). Không có cột này
thì màn danh sách nói "Tổng đề xuất" còn màn chi tiết nói "Giá duyệt"
trên CÙNG một phiếu đã duyệt ⇒ người dùng đọc ra hai con số. */}
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng duyệt</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
</tr>
</thead>
<tbody>
{list.isLoading && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
Đang tải...
</td>
</tr>
)}
{!list.isLoading && items.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào.'}
</td>
</tr>
)}
{items.map((k) => (
<tr
key={k.id}
onClick={() => !deletedView && navigate(`/khkk/${k.id}`)}
className={cn(
'border-b border-slate-100 transition last:border-0',
deletedView ? 'opacity-70' : 'cursor-pointer hover:bg-brand-50/50',
)}
>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-2">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileCheck className="h-3.5 w-3.5" />
</span>
<span className="font-mono text-xs text-brand-800">{k.maKeHoach ?? '—'}</span>
{/* [K2 S164] Badge NHÓM DUYỆT — đặt cạnh mã thay vì đẻ cột thứ 9:
bảng này đã cuộn ngang ở 1366 khi có cây bên trái (ghi chú S162).
`!= null` guard cho payload cũ còn trong cache React Query (trước
K2 DTO chưa có field) — thiếu guard thì in ra "Nundefined". */}
{k.approvalGroup != null && (
<span
className="inline-flex items-center rounded border border-violet-300 bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
>
N{k.approvalGroup}
</span>
)}
</span>
</td>
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-brand-800">
{k.peTenGoiThau ?? '—'}
{k.peMaPhieu && <span className="ml-2 font-mono text-[11px] text-slate-400">{k.peMaPhieu}</span>}
</td>
<td className="max-w-[14rem] truncate px-4 py-2.5 text-xs text-slate-600">{k.projectName ?? '—'}</td>
<td className="px-4 py-2.5">
<span
className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
KHKK_PHASE_BADGE[k.phase as KhkkPhaseValue],
)}
>
{KHKK_PHASE_LABELS[k.phase as KhkkPhaseValue]}
</span>
{k.currentApprovalLevelOrder && (
<span className="ml-2 text-xs text-slate-500">Cấp {k.currentApprovalLevelOrder}</span>
)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(k.totalProposedAmount)}</td>
{/* Phiếu chưa duyệt BE trả 0 (không phải null) ⇒ ép về "—" cho khỏi đọc
nhầm "đã duyệt 0 đ"; số chỉ hiện khi thật sự có tiền đã chốt. */}
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-700">
{k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'}
</td>
<td className="px-4 py-2.5 text-xs text-slate-600">{k.drafterFullName ?? '—'}</td>
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(k.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-slate-500">
{total} kế hoạch Trang {page} / {totalPages}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Sau
</Button>
</div>
</div>
)}
</div>
</div>
</aside>
</div>
</div>
)

View File

@ -1,491 +0,0 @@
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
// choke-point, KHÔNG phải việc của panel này.
//
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
// 🔴 [F-1 S166 — SUPERSEDE tiếp] Câu cũ "panel không có control riêng cho đường kết-thúc-sớm" HẾT hiệu lực:
// gate-K3 chấm nó là F-1 MAJOR (BE `?? true` mà 0 UI gửi false ⇒ trạm CEO chỉ đi được bằng admin bỏ cờ
// Designer). Nay panel CÓ ô-tích "Cấp này KẾT THÚC" theo khuôn PE-LIVE S97 = OPT-IN (G-1 reviewer:
// PE `useState(false)` :61 — owner đã ĐẢO opt-out S96 → opt-in S97; nhãn cũ "khuôn S96" trích trúng
// chú-thích chết). Default KHÔNG tick ⇒ gửi false ⇒ trình tiếp; TÍCH ⇒ true ⇒ BE kết thúc tại cấp.
// RATIFIED @S167 2026-08-01 (anh: "Đồng ý. Cứ theo khuôn PE") — default OPT-IN khuôn PE-live-S97 là CHỐT.
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import {
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
} from '@/types/khkk'
type NodeStatus = 'Done' | 'Current' | 'Pending'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
const STATUS_DOT: Record<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
Done: 'border-emerald-200 bg-emerald-50/40',
Current: 'border-brand-300 bg-brand-50/50',
Pending: 'border-slate-200 bg-white',
}
const STATUS_ICON: Record<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
export function KhkkWorkflowPanel({
plan,
onChanged,
}: {
plan: KhkkDetailDto
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
onChanged?: () => void
}) {
const qc = useQueryClient()
const { user } = useAuth()
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
const [comment, setComment] = useState('')
// [F-1 S166 · G-1 reviewer] Ô-tích level-finalize theo khuôn PE-LIVE S97 = OPT-IN
// (`PeWorkflowPanel.tsx:61` useState(false) — owner đảo opt-out S96 → opt-in S97 "cái này option
// thôi"; đừng trích comment S96 chết ở `:769-771` file đó). Default UNCHECKED = duyệt xong TRÌNH
// TIẾP cấp sau (fail-mode an toàn: không đọc thì phiếu vẫn lên CEO); TÍCH mới KẾT THÚC tại cấp.
// Hành vi mặc định trạm CCM đổi so trước-F-1 (BE `?? true` auto-finalize) — RATIFIED @S167 (khuôn PE);
// lật lại (nếu đổi ý) = đổi 1 chữ false→true + 2 nhãn dưới.
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? []
const opinions = plan.levelOpinions ?? []
const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
const stepStatus = (idx: number): NodeStatus => {
if (isApproved) return 'Done'
if (!isWaiting || curStepIdx == null) return 'Pending'
if (idx < curStepIdx) return 'Done'
if (idx === curStepIdx) return 'Current'
return 'Pending'
}
const levelStatus = (idx: number, order: number): NodeStatus => {
const s = stepStatus(idx)
if (s !== 'Current' || curLevelOrder == null) return s
if (order < curLevelOrder) return 'Done'
if (order === curLevelOrder) return 'Current'
return 'Pending'
}
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
const currentLevels: KhkkWorkflowLevelDto[] =
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
: []
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
? steps[curStepIdx]
: null
const isAdmin = user?.roles?.includes('Admin') ?? false
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
// [F-1 S166] Ô-tích opt-out hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel`
// (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC (kể cả Admin có slot riêng),
// Admin không có own ⇒ level ĐẦU nhóm cùng-order. Soi cờ của level KHÁC là hiện ô-tích nói dối.
const actingLevel = currentLevels.find(l => l.approverUserId === user?.id)
?? (isAdmin ? currentLevels[0] : undefined)
const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor DeptManager(cùng phòng) Admin`
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const transition = useMutation({
mutationFn: async (a: KhkkTransitionActionValue) => {
// 🔴 BODY theo hợp-đồng LEAD chốt W3 (`action` + `comment`) + [F-1 S166] field thứ 3
// `applyLevelFinalize` CHỈ đính kèm khi approve: cấp có cờ gửi giá trị ô-tích, cấp thường
// gửi true = no-op (mirror PE `PeWorkflowPanel.tsx:259`; BE `bool? = null` ⇒ absent = true).
const body: KhkkTransitionInput = {
action: a,
comment: comment.trim() || null,
...(a === KhkkTransitionAction.Approve
? { applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true }
: {}),
}
const res = await api.post<KhkkTransitionResult>(
`/contract-signing-plans/${plan.id}/transitions`,
body,
)
return res.data
},
onSuccess: (res) => {
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
setAction(null)
setComment('')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
onChanged?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const dialogTitle =
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
: '✗ Từ chối kế hoạch ký kết'
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
const commentRequired =
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
)}
</header>
<div className="space-y-4 p-5">
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex items-center gap-2 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
</div>
{step.levels.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{step.levels.map(lv => {
const ls = levelStatus(idx, lv.order)
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<li key={lv.id} className="text-[11px]">
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{lv.name || `Cấp ${lv.order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
</div>
<div className="text-slate-500">
{lv.approverFullName ?? '(chưa cấu hình)'}
{signed && (
<span className="ml-1.5 text-[10px] text-emerald-600">
{formatDateTime(signed.signedAt)}
</span>
)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Quy trình duyệt chưa cấu hình Bước/Cấp liên hệ Admin đ dựng quy trình loại
&ldquo;Kế hoạch kết &rdquo; trước khi trình duyệt.
</div>
)}
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{phase === KhkkPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
Kế hoạch bị TRẢ LẠI sửa nội dung rồi bấm &ldquo;Gửi duyệt&rdquo;, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{phase === KhkkPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch đã bị TỪ CHỐI không thao tác đưc nữa. Lập kế hoạch mới nếu cần làm lại.
</div>
)}
{/* Hành động */}
{(canSubmit || isWaiting) && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && (
<>
<button
type="button"
disabled={blockedByLevel}
onClick={() => {
if (!blockedByLevel) {
setComment('')
// [F-1 S166 · G-1] reset về default KHÔNG-tick (opt-IN, khuôn PE S97) mỗi
// lần mở dialog — lựa chọn tích là per-lượt-duyệt, không dính lần trước.
setApplyLevelFinalize(false)
setAction(KhkkTransitionAction.Approve)
}
}}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
<Check className="h-3 w-3" /> Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-red-300 text-red-700 hover:bg-red-50',
)}
>
<X className="h-3 w-3" /> Từ chối
</button>
</>
)}
</div>
</div>
)}
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
{opinions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
Ý kiến cấp duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{opinions.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{opinions.map(o => (
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{o.signedByFullName}</span>
<span className="text-slate-400">·</span>
<span>
{o.stepName ? `Bước ${o.stepOrder}${o.stepName}` : 'Cấp duyệt'}
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay
</span>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{o.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
</li>
))}
</ul>
</div>
)}
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === KhkkTransitionAction.Submit && (
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
Kế hoạch chuyển sang &ldquo;Chờ duyệt&rdquo; trình lên Bước 1 · Cấp 1 của quy trình
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa đưc nữa.
</div>
)}
{action === KhkkTransitionAction.Return && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Kế hoạch về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
{action === KhkkTransitionAction.Reject && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
</div>
)}
{/* [F-1 S166 · G-1/G-5 reviewer] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) = ô-tích
OPT-IN, khuôn PE-LIVE S97 (`PeWorkflowPanel.tsx:61` useState(false) + JSX `:772-791`;
comment "opt-out S96" ngay trên khối JSX đó là chú-thích CHẾT — G-1 bắt đúng bẫy này):
default KHÔNG tick = duyệt xong TRÌNH TIẾP cấp sau; TÍCH = gửi applyLevelFinalize=true
⇒ BE kết thúc tại cấp. Không viết "(CEO)" — cấp-sau là gì tuỳ cấu hình quy trình (G-5).
KHÁC PE: không nhắc "chọn giá chốt" — KHKK chốt giá per-dòng do BE tự làm ở finalize
choke-point (`ApplyApprovedValuesOnFinalize`), panel không có price-picker. */}
{action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5"
checked={applyLevelFinalize}
onChange={e => setApplyLevelFinalize(e.target.checked)}
/>
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Đã tích — duyệt xong là kế hoạch "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
: 'Mặc định: duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này.'}
</span>
</span>
</label>
</div>
)}
<Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
/>
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ).
</p>
)}
</Dialog>
)}
</section>
)
}

View File

@ -103,6 +103,10 @@ export const DOSSIER_STATUS_BADGE: Record<DossierItemStatusValue, string> = {
export const KhkkAttachmentPurpose = {
DossierScan: 1,
PlanDocument: 2,
// [S171 BE-7] File người DUYỆT đính kèm lúc bấm Duyệt/Trả lại/Từ chối (panel 3 mục 5).
// Upload qua đường RIÊNG `POST /contract-signing-plans/{id}/approval-attachments` —
// BE tự ép purpose, FE KHÔNG gửi số enum này lên.
ApprovalAttachment: 3,
Other: 99,
} as const
export type KhkkAttachmentPurposeValue =
@ -111,6 +115,7 @@ export type KhkkAttachmentPurposeValue =
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
1: 'Scan căn cứ',
2: 'Bản kế hoạch',
3: 'File duyệt',
99: 'Khác',
}
@ -272,13 +277,88 @@ export interface KhkkWorkflowLevelDto {
allowApproverFinalize: boolean
}
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
// Mirror `ContractSigningPlanWorkflowStepDto`.
export interface KhkkWorkflowStepDto {
id: string
order: number
name: string
departmentId: string | null
levels: KhkkWorkflowLevelDto[]
// [S171 BE-4] Tên phòng của Bước (BE join `Departments`). Nuôi ĐỒNG THỜI 2 chỗ:
// chip phòng ở panel 3 (P3-4) và header nhóm Section 5 (P2-5). null = Bước không gắn phòng.
departmentName: string | null
}
// [S171 BE-2] Lịch sử duyệt — panel 3 mục 6 "Lịch sử duyệt (N)" của Ảnh 2.
// 🔴 BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) để số `(N)` cùng nghĩa với `(N)`
// của PE. FE KHÔNG cần dedupe lại. Sự kiện "Gửi duyệt" vẫn còn ở `changelogs`.
export const KhkkApprovalDecision = {
Pending: 0,
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
export type KhkkApprovalDecisionValue =
(typeof KhkkApprovalDecision)[keyof typeof KhkkApprovalDecision]
export interface KhkkApprovalDto {
id: string
fromPhase: KhkkPhaseValue
toPhase: KhkkPhaseValue
decision: KhkkApprovalDecisionValue
comment: string | null
approvalWorkflowLevelId: string | null
// 4 field dưới DẪN XUẤT từ `approvalWorkflowLevelId` (loose-Guid, no-FK) qua cây workflow
// đã pin ⇒ null khi Cấp đã bị admin xoá. Dòng lịch sử VẪN hiện (BE không nuốt hàng).
stepOrder: number | null
stepName: string | null
levelOrder: number | null
levelName: string | null
approvedByUserId: string | null
approvedByFullName: string | null
approvedAt: string
}
// [S171 BE-5] "Lịch sử thay đổi" — `GET /contract-signing-plans/{id}/changelogs`
// (policy `KeHoachKyKet.Read` + rào hàng `EnsureCanViewAsync` ở handler).
export const KhkkChangelogEntityType = {
Header: 1,
Line: 2,
DossierItem: 3,
Workflow: 4,
Attachment: 5,
} as const
export type KhkkChangelogEntityTypeValue =
(typeof KhkkChangelogEntityType)[keyof typeof KhkkChangelogEntityType]
export const KHKK_CHANGELOG_ENTITY_TYPE_LABELS: Record<number, string> = {
1: 'Thông tin chung',
2: 'Dòng giá',
3: 'Căn cứ hồ sơ',
4: 'Quy trình',
5: 'File đính kèm',
}
// Mirror `ChangelogAction` (enum DÙNG CHUNG 3 module — `Domain/Contracts/ChangelogAction`).
export const KHKK_CHANGELOG_ACTION_LABELS: Record<number, string> = {
1: 'Tạo',
2: 'Cập nhật',
3: 'Xóa',
4: 'Chuyển trạng thái',
}
export interface KhkkChangelogDto {
id: string
entityType: KhkkChangelogEntityTypeValue
entityId: string | null
action: number
phaseAtChange: KhkkPhaseValue | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}
export interface KhkkDetailDto extends KhkkListItemDto {
@ -296,9 +376,23 @@ export interface KhkkDetailDto extends KhkkListItemDto {
// [K2 S164] `ContractSigningPlanDetailDto:166` — CỜ HIỂN THỊ dẫn xuất từ phase
// (`phase == DaDuyet`), KHÔNG phải cột DB. Số đã đóng băng ở tầng dữ liệu bởi
// `ApprovedAmount ??=` tại choke-point finalize. Khuôn tên `budgetFrozen` của PE S133.
// 🔸 BE **KHÔNG** expose `endedByLevelFinalize` ở DTO lượt này (cột Mig 71 nằm im chờ
// K3 tiêu thụ — ghost-window đã khai ở `sub-implbe-k2.md §a`) ⇒ FE không khai field đó.
budgetFrozen: boolean
// ===================== [S171 BE-1/BE-1b/BE-2] 5 field nuôi panel 3 ====================
// 🔸 Đính chính ghi-chú K2 cũ ("BE KHÔNG expose `endedByLevelFinalize`"): từ S171 CÓ expose.
/** Số phiên bản quy trình đã pin — dòng meta `{workflowCode} v{NN} · {workflowName}`. */
workflowVersion: number | null
/**
* Banner "kết thúc sớm — không qua CEO". 3-NGẢ ở BE, FE chỉ đọc:
* phiếu `DaDuyet` → CỜ RUNTIME thật (Mig 71); phiếu chưa xong → suy từ cấu hình nhưng
* POINTER-AWARE (chỉ tính cấp `allowApproverFinalize` ở-hoặc-SAU con-trỏ); chưa pin quy trình → false.
*/
endedByLevelFinalize: boolean
/** Tên Bước/Cấp có `allowApproverFinalize` ĐẦU TIÊN — nhãn CẤU HÌNH, KHÔNG có cột DB nào lưu. */
finalizeStepName: string | null
finalizeLevelName: string | null
/** Lịch sử duyệt (BE đã lọc `decision = Pending`). */
approvals: KhkkApprovalDto[]
}
// [K7 S167 — vá-4b] Tập id HỢP ĐỒNG mà một phiếu KHKK đã đẻ ra. Gom từ MỌI nguồn CÓ THẬT

View File

@ -81,6 +81,19 @@ public class ContractSigningPlansController(
public async Task<ActionResult<ContractSigningPlanDetailDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetContractSigningPlanQuery(id), ct));
/// [S171 BE-5] "Lịch sử thay đổi" panel 3 mục 7.
// 🔴 `KeHoachKyKet.Read` GHI TƯỜNG MINH đúng key của endpoint (gotcha #85 — KHÔNG OR key con).
// ⚠️ KHÔNG chép khuôn PE: `PurchaseEvaluationsController.cs:15` `[Authorize]` TRẦN + `:281`
// endpoint lịch-sử KHÔNG policy nào ⇒ đăng nhập bất kỳ là đọc được lịch sử phiếu (#82
// đang treo bên đó). Handler còn bịt thêm tầng hàng bằng `EnsureCanViewAsync`.
// 🔸 Comment này CỐ Ý không viết lại chuỗi route: thước B-5 đếm literal, chú-thích nhắc tên
// sẽ bơm tử-số (đúng lớp citation-trap mà B-6 vừa phải sửa).
[HttpGet("{id:guid}/changelogs")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<List<ContractSigningPlanChangelogDto>>> ListChangelogs(
Guid id, [FromQuery] int take = 200, CancellationToken ct = default)
=> Ok(await mediator.Send(new ListContractSigningPlanChangelogsQuery(id, take), ct));
// ========================= Ghi header =========================
[HttpPost]
@ -179,6 +192,33 @@ public class ContractSigningPlansController(
return Ok(dto);
}
/// [S171 BE-7 — owner chốt 2026-08-04] File người DUYỆT đính kèm trong dialog Duyệt/Trả lại/
/// Từ chối (panel 3 mục 5, Ảnh 2). Đường RIÊNG thay vì bắt FE tự truyền `purpose=3`:
/// • `Purpose` ép cứng `ApprovalAttachment` ở BE ⇒ FE không đặt nhầm mục đích, và khối
/// "📎 File đính kèm khi duyệt" đọc theo purpose không lẫn với file căn cứ b.8-9;
/// • `dossierItemId` KHÔNG nhận (file duyệt không treo vào căn cứ nào).
// 🔴 Policy `KeHoachKyKet.Update` — đây là đường GHI (gotcha #85: đúng key của endpoint, KHÔNG
// hạ xuống Read cho tiện, KHÔNG OR key con). Người duyệt cần quyền Update để bấm Duyệt
// (`/transitions` cùng policy) ⇒ đính kèm lúc duyệt không đẻ thêm rào mới cho họ.
[HttpPost("{id:guid}/approval-attachments")]
[Authorize(Policy = "KeHoachKyKet.Update")]
[RequestSizeLimit(25_000_000)]
public async Task<ActionResult<ContractSigningPlanAttachmentDto>> UploadApprovalAttachment(
Guid id,
IFormFile file,
[FromForm] string? note = null,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
return BadRequest(new { detail = "Chưa chọn file." });
await using var stream = file.OpenReadStream();
var dto = await mediator.Send(new UploadContractSigningPlanAttachmentCommand(
id, DossierItemId: null, file.FileName, file.ContentType, file.Length, stream,
ContractSigningPlanAttachmentPurpose.ApprovalAttachment, note), ct);
return Ok(dto);
}
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)

View File

@ -140,7 +140,32 @@ public record ContractSigningPlanWorkflowStepDto(
int Order,
string Name,
Guid? DepartmentId,
List<ContractSigningPlanWorkflowLevelDto> Levels);
List<ContractSigningPlanWorkflowLevelDto> Levels,
// [S171 BE-4] append-CUỐI (KHÔNG chèn cạnh `DepartmentId` — convention file này là
// append cuối, JSON bind theo TÊN nên FE không quan tâm vị trí). Tên phòng của Bước,
// nuôi ĐỒNG THỜI 2 chỗ: chip phòng ở panel 3 (P3-4) và header nhóm Section 5 (P2-5).
// Join `Departments` 1 lượt trong handler — `Step.DepartmentId` là loose-Guid (không nav).
string? DepartmentName = null);
// [S171 BE-2] Lịch sử duyệt của phiếu — record MỚI (trước S171 grep = 0 hit; panel 3 mục 6
// "Lịch sử duyệt (N)" của Ảnh 2 không có nguồn nào để đọc).
// 🔴 `StepOrder/StepName/LevelOrder/LevelName` DẪN XUẤT từ `ApprovalWorkflowLevelId`
// (loose-Guid, no-FK — `ContractSigningPlanApproval.cs:14-16`) qua bản-đồ Cấp của workflow đã
// pin. Cấp bị admin xoá ⇒ 4 field null, dòng lịch sử VẪN hiện (không nuốt hàng).
public record ContractSigningPlanApprovalDto(
Guid Id,
ContractSigningPlanPhase FromPhase,
ContractSigningPlanPhase ToPhase,
ApprovalDecision Decision,
string? Comment,
Guid? ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
string? LevelName,
Guid? ApprovedByUserId,
string? ApprovedByFullName,
DateTime ApprovedAt);
public record ContractSigningPlanDetailDto(
Guid Id,
@ -178,7 +203,21 @@ public record ContractSigningPlanDetailDto(
// + badge khoá; trước đó đọc `ProposedAmount`. Đây là CỜ HIỂN THỊ dẫn xuất từ Phase,
// KHÔNG phải cột DB — số đã được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại
// choke-point finalize (`ContractSigningPlanWorkflowService.cs:301-305`).
bool BudgetFrozen);
bool BudgetFrozen,
// ===================== [S171 BE-1/BE-1b/BE-2] append-CUỐI =========================
// Nuôi panel 3 "Quy trình duyệt" (Ảnh 2) — mirror khuôn PE.
// • `WorkflowVersion` — dòng meta `{code} v{NN} · {name}` (ApprovalWorkflow.Version:24).
// • `EndedByLevelFinalize` — CỜ RUNTIME đọc từ cột phiếu (Mig 71), banner "… không qua CEO".
// • `FinalizeStepName`/`FinalizeLevelName` — 🔴 KHÔNG có cột DB nào lưu "Bước/Cấp nào đã
// kết thúc" (`ContractSigningPlan.cs:51` chỉ có bool) ⇒ DẪN XUẤT từ cây workflow đã pin,
// đúng ngữ nghĩa PE list (`PurchaseEvaluationFeatures.cs:678-685`): Cấp `AllowApproverFinalize`
// ĐẦU TIÊN theo (Step.Order, Level.Order). Xem 3-ngả ở handler.
// • `Approvals` — lịch sử duyệt (BE-2). ĐÃ LỌC `Decision=Pending`, xem ghi chú handler.
int? WorkflowVersion,
bool EndedByLevelFinalize,
string? FinalizeStepName,
string? FinalizeLevelName,
List<ContractSigningPlanApprovalDto> Approvals);
/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
public record ApprovedPeAwaitingPlanDto(
@ -702,8 +741,12 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
// ---- Workflow tree + metadata Cấp cho LevelOpinions.
string? wfCode = null, wfName = null;
int? wfVersion = null; // [S171 BE-1] meta `{code} v{NN}`
string? finalizeStepName = null, finalizeLevelName = null; // [S171 BE-1b] DẪN XUẤT
var endedByLevelFinalize = false; // [S171 BE-1b] 3-ngả, xem dưới
var stepDtos = new List<ContractSigningPlanWorkflowStepDto>();
var levelMeta = new Dictionary<Guid, (int StepOrder, string StepName, int LevelOrder, Guid ApproverUserId)>();
var levelMeta = new Dictionary<Guid,
(int StepOrder, string StepName, int LevelOrder, string? LevelName, Guid ApproverUserId)>();
if (plan.ApprovalWorkflowId is Guid wfId)
{
@ -715,6 +758,7 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
{
wfCode = wf.Code;
wfName = wf.Name;
wfVersion = wf.Version;
var approverIds = wf.Steps.SelectMany(s => s.Levels).Select(l => l.ApproverUserId)
.Distinct().ToList();
var approverNames = await db.Users.AsNoTracking()
@ -722,18 +766,64 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
.Select(u => new { u.Id, u.FullName })
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
foreach (var s in wf.Steps.OrderBy(s => s.Order))
// [S171 BE-4] Tên phòng của Bước — `Step.DepartmentId` loose-Guid (không nav) ⇒
// 1 lượt truy gom, KHÔNG N+1 trong vòng lặp.
var stepDeptIds = wf.Steps.Where(s => s.DepartmentId.HasValue)
.Select(s => s.DepartmentId!.Value).Distinct().ToList();
var stepDeptNames = stepDeptIds.Count == 0
? new Dictionary<Guid, string>()
: await db.Departments.AsNoTracking()
.Where(d => stepDeptIds.Contains(d.Id))
.Select(d => new { d.Id, d.Name })
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
var sortedSteps = wf.Steps.OrderBy(s => s.Order).ToList();
for (var i = 0; i < sortedSteps.Count; i++)
{
var s = sortedSteps[i];
stepDtos.Add(new ContractSigningPlanWorkflowStepDto(
s.Id, s.Order, s.Name, s.DepartmentId,
s.Levels.OrderBy(l => l.Order).Select(l => new ContractSigningPlanWorkflowLevelDto(
l.Id, l.Order, l.Name, l.ApproverUserId,
approverNames.TryGetValue(l.ApproverUserId, out var fn) ? fn : null,
l.AllowApproverFinalize)).ToList()));
l.AllowApproverFinalize)).ToList(),
s.DepartmentId is Guid dId && stepDeptNames.TryGetValue(dId, out var dn) ? dn : null));
foreach (var l in s.Levels)
levelMeta[l.Id] = (s.Order, s.Name, l.Order, l.ApproverUserId);
levelMeta[l.Id] = (s.Order, s.Name, l.Order, l.Name, l.ApproverUserId);
}
// ---- [S171 BE-1b] "Kết thúc tại cấp" — KHÔNG có cột DB nào lưu Bước/Cấp nào đã
// kết thúc (`ContractSigningPlan.cs:51` chỉ có bool) ⇒ DẪN XUẤT tại chỗ. Ngữ nghĩa
// mirror PE list `PurchaseEvaluationFeatures.cs:670-685`, làm IN-MEMORY (cây đã nạp
// đủ ở trên) thay vì port 3 subquery — cùng kết quả, bớt 3 lượt truy.
// 🔴 KHÁC PE MỘT CHỖ, CỐ Ý: PE so `Step.Order - 1` với con-trỏ; KHKK
// `CurrentWorkflowStepIndex` là INDEX 0-based vào list ĐÃ SORT
// (`ContractSigningPlanWorkflowService.cs:26,422-435`), KHÔNG phải `Order - 1`.
// Chép nguyên `Order - 1` sẽ sai ngay khi admin để Order không tuần-tự 1..N.
var finalizeLevels = sortedSteps
.SelectMany((s, idx) => s.Levels.OrderBy(l => l.Order)
.Where(l => l.AllowApproverFinalize)
.Select(l => new { StepIdx = idx, Step = s, Level = l }))
.ToList();
// Tên Bước/Cấp = cấp finalize ĐẦU TIÊN của quy trình (KHÔNG lọc theo con-trỏ —
// đúng như PE `:678-685`): đây là nhãn CẤU HÌNH "quy trình này kết thúc ở đâu".
var firstFinalize = finalizeLevels.FirstOrDefault();
finalizeStepName = firstFinalize?.Step.Name;
finalizeLevelName = firstFinalize?.Level.Name;
// 3-ngả: phiếu ĐÃ DUYỆT đọc CỜ RUNTIME (finalize sớm THẬT đã xảy ra hay chưa —
// bài S97 gotcha #78/#79: suy từ config là sai khi người duyệt bỏ tick opt-out);
// phiếu chưa xong thì POINTER-AWARE (chỉ tính cấp finalize ở-hoặc-SAU con-trỏ, để
// không dán banner "kết thúc sớm" khi phiếu đã QUA cấp đó và đang lên CEO);
// không pin workflow ⇒ danh sách rỗng ⇒ false.
var curIdx = plan.CurrentWorkflowStepIndex ?? 0;
var curLevel = plan.CurrentApprovalLevelOrder ?? 1;
endedByLevelFinalize = plan.Phase == ContractSigningPlanPhase.DaDuyet
? plan.EndedByLevelFinalize
: finalizeLevels.Any(x => x.StepIdx > curIdx
|| (x.StepIdx == curIdx && x.Level.Order >= curLevel));
}
}
@ -781,6 +871,54 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList();
// ---- [S171 BE-2/BE-3/BE-6] Lịch sử duyệt (panel 3 mục 6 của Ảnh 2).
// 🔴 LỌC `Decision = Pending`: hàng Pending là hành-vi GỬI duyệt (`...WorkflowService.cs:204`),
// KHÔNG phải một lần ai đó bấm Duyệt/Trả lại/Từ chối. Để nguyên thì số `(N)` của KHKK
// đếm khác nghĩa với `(N)` của PE (PE dedupe ở FE `PeDetailTabs.tsx:3152-3192`) — trong
// khi cả đợt này là "cho đồng nhất". KHÔNG mất thông tin: mọi transition kể cả Gửi duyệt
// đều có 1 hàng ở `ContractSigningPlanChangelogs` (cùng hàm ghi `:546`) → khối
// "Lịch sử thay đổi" vẫn hiện đủ.
var approvalRows = await db.ContractSigningPlanApprovals.AsNoTracking()
.Where(a => a.ContractSigningPlanId == plan.Id && a.Decision != ApprovalDecision.Pending)
.OrderByDescending(a => a.ApprovedAt)
.ToListAsync(ct);
// [BE-3] Tên người duyệt. `ContractSigningPlanApproval` KHÔNG denorm tên (khác hàng
// changelog anh-em `:546-557` CÓ `UserName`).
// 🔴 Chọn JOIN `Users`, KHÔNG lấy tên từ changelog cùng transition: 2 hàng đó ghi cùng
// unit-of-work nhưng KHÔNG có khoá nối chính xác — `Approval.ApprovedAt` là `now` của
// domain còn `Changelog.CreatedAt` do AuditingInterceptor đóng lúc SaveChanges ⇒ lệch ms,
// ghép lại phải dùng "bucket thời gian" (đúng cái hack 5s PE đang phải chịu ở FE).
// Phương án denorm thêm cột tên vào Approval thì cần MIGRATION — ngoài phạm vi đợt UI này.
var approverActorIds = approvalRows
.Where(a => a.ApprovedByUserId.HasValue).Select(a => a.ApprovedByUserId!.Value)
.Distinct().ToList();
var approverActorNames = approverActorIds.Count == 0
? new Dictionary<Guid, string>()
: await db.Users.AsNoTracking()
.Where(u => approverActorIds.Contains(u.Id))
.Select(u => new { u.Id, u.FullName })
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
var approvals = approvalRows
.Select(a =>
{
var known = a.ApprovalWorkflowLevelId is Guid lid && levelMeta.ContainsKey(lid);
var meta = known ? levelMeta[a.ApprovalWorkflowLevelId!.Value] : default;
return new ContractSigningPlanApprovalDto(
a.Id, a.FromPhase, a.ToPhase, a.Decision, a.Comment,
a.ApprovalWorkflowLevelId,
known ? meta.StepOrder : null,
known ? meta.StepName : null,
known ? meta.LevelOrder : null,
known ? meta.LevelName : null,
a.ApprovedByUserId,
a.ApprovedByUserId is Guid uid && approverActorNames.TryGetValue(uid, out var an)
? an : null,
a.ApprovedAt);
})
.ToList();
return new ContractSigningPlanDetailDto(
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
plan.ProjectId, project?.Name, project?.Code,
@ -795,7 +933,9 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
// được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại choke-point finalize
// (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
// bảo FE "đọc `ApprovedAmount` + khoá ô" thay vì đọc `ProposedAmount`.
plan.Phase == ContractSigningPlanPhase.DaDuyet);
plan.Phase == ContractSigningPlanPhase.DaDuyet,
// [S171 BE-1/BE-1b/BE-2] 5 field nuôi panel 3.
wfVersion, endedByLevelFinalize, finalizeStepName, finalizeLevelName, approvals);
}
}
@ -1451,3 +1591,55 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
select pl.ApprovalGroup).ToList())).ToListAsync(ct);
}
}
// ==================== Region 8: CHANGELOG list (S171 BE-5) ==========================
// Panel 3 mục 7 "Lịch sử thay đổi" — nguồn `ContractSigningPlanChangelogs` (ghi từ ngày 1,
// 10 write-site kể cả cầu KHKK→HĐ `CreateContractFromSigningPlanFeatures.cs:226`).
// Khuôn `ListPurchaseEvaluationChangelogsQueryHandler` (`PurchaseEvaluationFeatures.cs:1419`)
// — 🔴 NHƯNG có THÊM rào xem, xem ghi chú ở handler.
public record ContractSigningPlanChangelogDto(
Guid Id,
ContractSigningPlanEntityType EntityType,
Guid? EntityId,
ChangelogAction Action,
ContractSigningPlanPhase? PhaseAtChange,
Guid? UserId,
string? UserName,
string? Summary,
string? FieldChangesJson,
string? ContextNote,
DateTime CreatedAt);
public record ListContractSigningPlanChangelogsQuery(Guid ContractSigningPlanId, int Take = 200)
: IRequest<List<ContractSigningPlanChangelogDto>>;
public class ListContractSigningPlanChangelogsQueryHandler(
IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<ListContractSigningPlanChangelogsQuery, List<ContractSigningPlanChangelogDto>>
{
public async Task<List<ContractSigningPlanChangelogDto>> Handle(
ListContractSigningPlanChangelogsQuery request, CancellationToken ct)
{
// 🔴 AUTHZ 2 TẦNG — tầng policy `KeHoachKyKet.Read` ở controller CHƯA ĐỦ (gotcha #82):
// policy chỉ nói "được vào module", không nói "được đọc PHIẾU NÀY". Detail đã bịt bằng
// `EnsureCanViewAsync` (`:678-679`) ⇒ lịch sử của cùng phiếu phải bịt CÙNG VỊ NGỮ, nếu
// không thì bịt cửa chính còn mở cửa sau (nội dung ý kiến/ghi chú duyệt nằm ở đây).
// ⚠️ CỐ Ý KHÔNG chép PE: `PurchaseEvaluationsController.cs:281` KHÔNG có policy nào và
// handler PE cũng không có rào hàng — đó là lỗ #82 thứ hai đang treo, KHÔNG phải mẫu.
var plan = await db.ContractSigningPlans.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);
return await db.ContractSigningPlanChangelogs.AsNoTracking()
.Where(c => c.ContractSigningPlanId == request.ContractSigningPlanId)
.OrderByDescending(c => c.CreatedAt)
.Take(request.Take)
.Select(c => new ContractSigningPlanChangelogDto(
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
c.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -8,6 +8,10 @@ public enum ContractSigningPlanAttachmentPurpose
{
DossierScan = 1, // Bản scan căn cứ (mẫu vật liệu / shopdrawing / kết quả TVGS)
PlanDocument = 2, // Bản kế hoạch ký kết đã ký/xuất
// [S171 BE-7 — owner chốt 2026-08-04] File người DUYỆT đính kèm lúc bấm Duyệt/Trả lại/Từ chối
// (panel 3 mục 5 của Ảnh 2). APPEND-ONLY đúng luật dòng trên: KHÔNG đánh số lại `Other = 99`
// — Purpose lưu là int trong DB, đổi số cũ = viết lại nghĩa của hàng đã có.
ApprovalAttachment = 3,
Other = 99,
}