[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:
709
fe-admin/src/components/khkk/KhkkDetailContent.tsx
Normal file
709
fe-admin/src/components/khkk/KhkkDetailContent.tsx
Normal 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ồ sơ (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 có 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">
|
||||||
|
✓ HĐ
|
||||||
|
</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 có 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 là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||||
|
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
||||||
|
</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 có 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ó 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 ký</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ử lý</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
388
fe-admin/src/components/khkk/KhkkListPanel.tsx
Normal file
388
fe-admin/src/components/khkk/KhkkListPanel.tsx
Normal 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 có 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ý kết vẫn liệt kê đầ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ý 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
726
fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx
Normal file
726
fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx
Normal 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
|
||||||
|
“Kế hoạch ký kết HĐ” 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 “Gửi duyệt”, 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 có 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 có 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 “Chờ duyệt” và 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 “Trả lại”. 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 và hiện ở mục “File đính kèm khi duyệt”.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action === KhkkTransitionAction.Approve && (
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||||
|
thì ghi đè ô cũ).
|
||||||
|
</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 và khỏi cây toàn trình, chỉ còn xem được ở mục “Đã xóa”.
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { ListTree, Search } from 'lucide-react'
|
import { ListTree, Search } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
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 { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Paged } from '@/types/master'
|
||||||
import type { PeListItem } from '@/types/purchaseEvaluation'
|
import type { PeListItem } from '@/types/purchaseEvaluation'
|
||||||
@ -87,9 +87,14 @@ export function PipelineTreePanel({
|
|||||||
currentStage,
|
currentStage,
|
||||||
className,
|
className,
|
||||||
bodyMaxHClass,
|
bodyMaxHClass,
|
||||||
|
stage2Options,
|
||||||
}: {
|
}: {
|
||||||
currentStage: PipelineStageNo
|
currentStage: PipelineStageNo
|
||||||
className?: string
|
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
|
// [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 100vh−16rem là
|
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh−16rem là
|
||||||
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
|
// 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"
|
className="ml-3 border-l border-slate-200"
|
||||||
currentStage={currentStage}
|
currentStage={currentStage}
|
||||||
hideEmpty={hideEmpty}
|
hideEmpty={hideEmpty}
|
||||||
stages={buildStages(wg.items)}
|
stages={buildStages(wg.items, stage2Options ?? {})}
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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). */
|
/** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
|
||||||
stage1Content?: ReactNode
|
stage1Content?: ReactNode
|
||||||
stage1Count?: number
|
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,
|
secondary: k.peTenGoiThau,
|
||||||
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
||||||
badgeClass: KHKK_PHASE_BADGE[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)
|
const khkkLeaves = khkkItems.map(toKhkkLeaf)
|
||||||
|
|
||||||
@ -265,7 +287,14 @@ export function usePipelineStages() {
|
|||||||
const stage2: PipelineStage = {
|
const stage2: PipelineStage = {
|
||||||
n: 2,
|
n: 2,
|
||||||
leaves: khkkLeaves,
|
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,
|
locked: !linkage.canKhkk,
|
||||||
failed: linkage.khkkFailed,
|
failed: linkage.khkkFailed,
|
||||||
loading: linkage.khkkLoading,
|
loading: linkage.khkkLoading,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,49 +1,47 @@
|
|||||||
// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2).
|
// [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
|
||||||
// Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng
|
// Duyệt NCC (GĐ1, `pages/pe/PurchaseEvaluationsListPage.tsx:381,397`) và Hợp đồng
|
||||||
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
|
// (GĐ3, `pages/contracts/ContractsListPage.tsx:106,125`). Trước S171 trang này là
|
||||||
// fe-admin counterpart.
|
// `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
|
// Khuôn code lấy từ `ContractsListPage.tsx` (255 dòng, gọn) — CỐ Ý KHÔNG chép
|
||||||
// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
|
// `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_List / Khkk_G{n}_List → /khkk/list?group={n}
|
||||||
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
|
// 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_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
|
||||||
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
|
// 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
|
// 🔴 [M-4] KHOÁ URL ĐƯỢC PHÉP GHI = `id` (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`).
|
||||||
// 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
|
// TUYỆT ĐỐI KHÔNG ghi `filter` / `view` / `group` vào `setSearchParams`: đứng ở leaf
|
||||||
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
|
// `?group=1` mà ghi thêm `filter=DaDuyet` là URL khớp CHÍNH XÁC leaf "Đã duyệt" của nhóm ⇒
|
||||||
// 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).
|
// 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
|
||||||
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
|
// (state) như bản cũ, không ghi ngược URL.
|
||||||
import { useMemo, useState, type ReactNode } from 'react'
|
//
|
||||||
|
// 🔴 [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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import { FileCheck, Plus, X } from 'lucide-react'
|
||||||
Plus, Search, FileCheck, FileEdit, SendHorizontal,
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
|
||||||
import { KpiCard } from '@/components/ui/KpiCard'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
||||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
|
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import {
|
import {
|
||||||
KHKK_APPROVAL_GROUP_LABELS,
|
|
||||||
KHKK_PHASE_BADGE,
|
|
||||||
KHKK_PHASE_LABELS,
|
|
||||||
KhkkPhase,
|
KhkkPhase,
|
||||||
khkkGroupMenuLabel,
|
khkkGroupMenuLabel,
|
||||||
type KhkkListItemDto,
|
type KhkkDetailDto,
|
||||||
type KhkkPhaseValue,
|
type KhkkPhaseValue,
|
||||||
type PagedResult,
|
|
||||||
} from '@/types/khkk'
|
} 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).
|
// 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> = {
|
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
|
||||||
DangSoanThao: KhkkPhase.DangSoanThao,
|
DangSoanThao: KhkkPhase.DangSoanThao,
|
||||||
@ -53,308 +51,156 @@ const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
|
|||||||
TuChoi: KhkkPhase.TuChoi,
|
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() {
|
export function KhkkListPage() {
|
||||||
const navigate = useNavigate()
|
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 ----
|
// ---- 3 trục DANH TÍNH đọc thẳng từ URL (chỉ ĐỌC — không bao giờ ghi lại, xem M-4) ----
|
||||||
// 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).
|
|
||||||
const urlFilter = searchParams.get('filter')
|
const urlFilter = searchParams.get('filter')
|
||||||
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
|
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
|
||||||
const deletedView = searchParams.get('view') === 'deleted'
|
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 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 ----
|
// ---- 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 bấm chip ⇒ nghe theo URL;
|
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa chạm ⇒ nghe theo URL; `null` = user
|
||||||
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
|
// chủ động chọn "Tất cả"; số = user chọn một giá trị. Gộp `undefined` với `null` thì chọn
|
||||||
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
|
// "Tất cả" sẽ bị URL kéo ngược về bộ lọc cũ.
|
||||||
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
|
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
|
||||||
|
const [groupOverride, setGroupOverride] = useState<number | null | undefined>(undefined)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
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
|
// Đổi leaf ⇒ trả bộ lọc về cho URL và về trang 1. `search` CỐ Ý giữ lại: đang tìm một mã
|
||||||
// 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
|
// rồi đổi nhóm để dò tiếp là thao tác có thật.
|
||||||
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
|
const navKey = `${urlGroup ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
|
||||||
// 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' : ''}`
|
|
||||||
const [lastNavKey, setLastNavKey] = useState(navKey)
|
const [lastNavKey, setLastNavKey] = useState(navKey)
|
||||||
if (navKey !== lastNavKey) {
|
if (navKey !== lastNavKey) {
|
||||||
setLastNavKey(navKey)
|
setLastNavKey(navKey)
|
||||||
setChipPhase(undefined)
|
setChipPhase(undefined)
|
||||||
|
setGroupOverride(undefined)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
|
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
|
||||||
|
const group: number | null = groupOverride !== undefined ? groupOverride : urlGroup
|
||||||
|
|
||||||
const list = useQuery({
|
const detail = useQuery({
|
||||||
// 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
|
queryKey: ['khkk-detail', selectedId],
|
||||||
// 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
|
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${selectedId}`)).data,
|
||||||
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
|
enabled: !!selectedId,
|
||||||
// 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 items = list.data?.items ?? []
|
function setId(id: string | null) {
|
||||||
const total = list.data?.total ?? 0
|
const next = new URLSearchParams(searchParams)
|
||||||
const totalPages = list.data?.totalPages ?? 1
|
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
|
function selectPlan(id: string) {
|
||||||
// hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh.
|
// `<lg` không có 3 panel ⇒ mở trang chi tiết đầy đủ (route `/khkk/:id` GIỮ NGUYÊN).
|
||||||
const countByPhase = useMemo(() => {
|
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||||
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 }
|
setId(id)
|
||||||
for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1
|
} else {
|
||||||
return acc
|
navigate(group !== null ? `/khkk/${id}?group=${group}` : `/khkk/${id}`)
|
||||||
}, [items])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const phaseCards: Array<{
|
const plan = detail.data
|
||||||
value: KhkkPhaseValue | null
|
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
|
||||||
label: string
|
const readOnly =
|
||||||
icon: ReactNode
|
!plan
|
||||||
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
|| !(plan.phase === KhkkPhase.DangSoanThao || plan.phase === KhkkPhase.TraLai)
|
||||||
count: number
|
|| (!!user?.id && plan.drafterUserId !== user.id)
|
||||||
}> = [
|
|
||||||
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
|
const headerTitle = deletedView
|
||||||
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
|
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
|
||||||
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
|
: group !== null ? `Kế hoạch ký kết HĐ — N${group}` : 'Kế hoạch ký kết HĐ'
|
||||||
{ 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] },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
<PageHeader
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
eyebrow="Giai đoạn 2"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
title={
|
<FileCheck className="h-5 w-5 text-slate-500" />
|
||||||
deletedView
|
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
|
<span className="ml-2 truncate rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
: group !== null
|
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''}
|
||||||
? `Kế hoạch ký kết HĐ — N${group}`
|
</span>
|
||||||
: '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`).
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* [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)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!deletedView && (
|
||||||
|
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
|
||||||
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Search className="h-4 w-4 shrink-0 text-slate-400" />
|
Lập kế hoạch
|
||||||
<Input
|
</Button>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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">Mã 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>
|
</header>
|
||||||
</div>
|
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
{selectedId && plan && (
|
||||||
|
<KhkkWorkflowPanel
|
||||||
|
plan={plan}
|
||||||
|
onChanged={() => detail.refetch()}
|
||||||
|
onDeleted={() => setId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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">
|
|
||||||
✓ ký {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
|
|
||||||
“Kế hoạch ký kết HĐ” 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 “Gửi duyệt”, 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 “Chờ duyệt” và 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 “Trả lại”. 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 “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
|
||||||
thì ghi đè ô cũ).
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -103,6 +103,10 @@ export const DOSSIER_STATUS_BADGE: Record<DossierItemStatusValue, string> = {
|
|||||||
export const KhkkAttachmentPurpose = {
|
export const KhkkAttachmentPurpose = {
|
||||||
DossierScan: 1,
|
DossierScan: 1,
|
||||||
PlanDocument: 2,
|
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,
|
Other: 99,
|
||||||
} as const
|
} as const
|
||||||
export type KhkkAttachmentPurposeValue =
|
export type KhkkAttachmentPurposeValue =
|
||||||
@ -111,6 +115,7 @@ export type KhkkAttachmentPurposeValue =
|
|||||||
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
|
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
|
||||||
1: 'Scan căn cứ',
|
1: 'Scan căn cứ',
|
||||||
2: 'Bản kế hoạch',
|
2: 'Bản kế hoạch',
|
||||||
|
3: 'File duyệt',
|
||||||
99: 'Khác',
|
99: 'Khác',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,13 +277,88 @@ export interface KhkkWorkflowLevelDto {
|
|||||||
allowApproverFinalize: boolean
|
allowApproverFinalize: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
|
// Mirror `ContractSigningPlanWorkflowStepDto`.
|
||||||
export interface KhkkWorkflowStepDto {
|
export interface KhkkWorkflowStepDto {
|
||||||
id: string
|
id: string
|
||||||
order: number
|
order: number
|
||||||
name: string
|
name: string
|
||||||
departmentId: string | null
|
departmentId: string | null
|
||||||
levels: KhkkWorkflowLevelDto[]
|
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 {
|
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
|
// [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
|
// (`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.
|
// `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
|
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
|
// [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
|
||||||
|
|||||||
709
fe-user/src/components/khkk/KhkkDetailContent.tsx
Normal file
709
fe-user/src/components/khkk/KhkkDetailContent.tsx
Normal 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ồ sơ (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 có 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">
|
||||||
|
✓ HĐ
|
||||||
|
</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 có 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 là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||||
|
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
||||||
|
</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 có 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ó 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 ký</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ử lý</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
388
fe-user/src/components/khkk/KhkkListPanel.tsx
Normal file
388
fe-user/src/components/khkk/KhkkListPanel.tsx
Normal 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 có 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ý kết vẫn liệt kê đầ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ý 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
726
fe-user/src/components/khkk/KhkkWorkflowPanel.tsx
Normal file
726
fe-user/src/components/khkk/KhkkWorkflowPanel.tsx
Normal 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
|
||||||
|
“Kế hoạch ký kết HĐ” 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 “Gửi duyệt”, 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 có 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 có 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 “Chờ duyệt” và 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 “Trả lại”. 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 và hiện ở mục “File đính kèm khi duyệt”.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action === KhkkTransitionAction.Approve && (
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||||
|
thì ghi đè ô cũ).
|
||||||
|
</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 và khỏi cây toàn trình, chỉ còn xem được ở mục “Đã xóa”.
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { ListTree, Search } from 'lucide-react'
|
import { ListTree, Search } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
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 { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Paged } from '@/types/master'
|
||||||
import type { PeListItem } from '@/types/purchaseEvaluation'
|
import type { PeListItem } from '@/types/purchaseEvaluation'
|
||||||
@ -87,9 +87,14 @@ export function PipelineTreePanel({
|
|||||||
currentStage,
|
currentStage,
|
||||||
className,
|
className,
|
||||||
bodyMaxHClass,
|
bodyMaxHClass,
|
||||||
|
stage2Options,
|
||||||
}: {
|
}: {
|
||||||
currentStage: PipelineStageNo
|
currentStage: PipelineStageNo
|
||||||
className?: string
|
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
|
// [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 100vh−16rem là
|
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh−16rem là
|
||||||
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
|
// 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"
|
className="ml-3 border-l border-slate-200"
|
||||||
currentStage={currentStage}
|
currentStage={currentStage}
|
||||||
hideEmpty={hideEmpty}
|
hideEmpty={hideEmpty}
|
||||||
stages={buildStages(wg.items)}
|
stages={buildStages(wg.items, stage2Options ?? {})}
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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). */
|
/** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
|
||||||
stage1Content?: ReactNode
|
stage1Content?: ReactNode
|
||||||
stage1Count?: number
|
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,
|
secondary: k.peTenGoiThau,
|
||||||
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
||||||
badgeClass: KHKK_PHASE_BADGE[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)
|
const khkkLeaves = khkkItems.map(toKhkkLeaf)
|
||||||
|
|
||||||
@ -265,7 +287,14 @@ export function usePipelineStages() {
|
|||||||
const stage2: PipelineStage = {
|
const stage2: PipelineStage = {
|
||||||
n: 2,
|
n: 2,
|
||||||
leaves: khkkLeaves,
|
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,
|
locked: !linkage.canKhkk,
|
||||||
failed: linkage.khkkFailed,
|
failed: linkage.khkkFailed,
|
||||||
loading: linkage.khkkLoading,
|
loading: linkage.khkkLoading,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,49 +1,47 @@
|
|||||||
// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2).
|
// [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
|
||||||
// Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng
|
// Duyệt NCC (GĐ1, `pages/pe/PurchaseEvaluationsListPage.tsx:381,397`) và Hợp đồng
|
||||||
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
|
// (GĐ3, `pages/contracts/ContractsListPage.tsx:106,125`). Trước S171 trang này là
|
||||||
// fe-admin counterpart.
|
// `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
|
// Khuôn code lấy từ `ContractsListPage.tsx` (255 dòng, gọn) — CỐ Ý KHÔNG chép
|
||||||
// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
|
// `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_List / Khkk_G{n}_List → /khkk/list?group={n}
|
||||||
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
|
// 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_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
|
||||||
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
|
// 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
|
// 🔴 [M-4] KHOÁ URL ĐƯỢC PHÉP GHI = `id` (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`).
|
||||||
// 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
|
// TUYỆT ĐỐI KHÔNG ghi `filter` / `view` / `group` vào `setSearchParams`: đứng ở leaf
|
||||||
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
|
// `?group=1` mà ghi thêm `filter=DaDuyet` là URL khớp CHÍNH XÁC leaf "Đã duyệt" của nhóm ⇒
|
||||||
// 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).
|
// 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
|
||||||
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
|
// (state) như bản cũ, không ghi ngược URL.
|
||||||
import { useMemo, useState, type ReactNode } from 'react'
|
//
|
||||||
|
// 🔴 [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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import { FileCheck, Plus, X } from 'lucide-react'
|
||||||
Plus, Search, FileCheck, FileEdit, SendHorizontal,
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
|
||||||
import { KpiCard } from '@/components/ui/KpiCard'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
||||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||||
|
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import {
|
import {
|
||||||
KHKK_APPROVAL_GROUP_LABELS,
|
|
||||||
KHKK_PHASE_BADGE,
|
|
||||||
KHKK_PHASE_LABELS,
|
|
||||||
KhkkPhase,
|
KhkkPhase,
|
||||||
khkkGroupMenuLabel,
|
khkkGroupMenuLabel,
|
||||||
type KhkkListItemDto,
|
type KhkkDetailDto,
|
||||||
type KhkkPhaseValue,
|
type KhkkPhaseValue,
|
||||||
type PagedResult,
|
|
||||||
} from '@/types/khkk'
|
} 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).
|
// 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> = {
|
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
|
||||||
DangSoanThao: KhkkPhase.DangSoanThao,
|
DangSoanThao: KhkkPhase.DangSoanThao,
|
||||||
@ -53,308 +51,156 @@ const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
|
|||||||
TuChoi: KhkkPhase.TuChoi,
|
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() {
|
export function KhkkListPage() {
|
||||||
const navigate = useNavigate()
|
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 ----
|
// ---- 3 trục DANH TÍNH đọc thẳng từ URL (chỉ ĐỌC — không bao giờ ghi lại, xem M-4) ----
|
||||||
// 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).
|
|
||||||
const urlFilter = searchParams.get('filter')
|
const urlFilter = searchParams.get('filter')
|
||||||
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
|
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
|
||||||
const deletedView = searchParams.get('view') === 'deleted'
|
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 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 ----
|
// ---- 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 bấm chip ⇒ nghe theo URL;
|
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa chạm ⇒ nghe theo URL; `null` = user
|
||||||
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
|
// chủ động chọn "Tất cả"; số = user chọn một giá trị. Gộp `undefined` với `null` thì chọn
|
||||||
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
|
// "Tất cả" sẽ bị URL kéo ngược về bộ lọc cũ.
|
||||||
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
|
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
|
||||||
|
const [groupOverride, setGroupOverride] = useState<number | null | undefined>(undefined)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
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
|
// Đổi leaf ⇒ trả bộ lọc về cho URL và về trang 1. `search` CỐ Ý giữ lại: đang tìm một mã
|
||||||
// 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
|
// rồi đổi nhóm để dò tiếp là thao tác có thật.
|
||||||
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
|
const navKey = `${urlGroup ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
|
||||||
// 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' : ''}`
|
|
||||||
const [lastNavKey, setLastNavKey] = useState(navKey)
|
const [lastNavKey, setLastNavKey] = useState(navKey)
|
||||||
if (navKey !== lastNavKey) {
|
if (navKey !== lastNavKey) {
|
||||||
setLastNavKey(navKey)
|
setLastNavKey(navKey)
|
||||||
setChipPhase(undefined)
|
setChipPhase(undefined)
|
||||||
|
setGroupOverride(undefined)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
|
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
|
||||||
|
const group: number | null = groupOverride !== undefined ? groupOverride : urlGroup
|
||||||
|
|
||||||
const list = useQuery({
|
const detail = useQuery({
|
||||||
// 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
|
queryKey: ['khkk-detail', selectedId],
|
||||||
// 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
|
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${selectedId}`)).data,
|
||||||
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
|
enabled: !!selectedId,
|
||||||
// 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 items = list.data?.items ?? []
|
function setId(id: string | null) {
|
||||||
const total = list.data?.total ?? 0
|
const next = new URLSearchParams(searchParams)
|
||||||
const totalPages = list.data?.totalPages ?? 1
|
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
|
function selectPlan(id: string) {
|
||||||
// hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh.
|
// `<lg` không có 3 panel ⇒ mở trang chi tiết đầy đủ (route `/khkk/:id` GIỮ NGUYÊN).
|
||||||
const countByPhase = useMemo(() => {
|
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||||
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 }
|
setId(id)
|
||||||
for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1
|
} else {
|
||||||
return acc
|
navigate(group !== null ? `/khkk/${id}?group=${group}` : `/khkk/${id}`)
|
||||||
}, [items])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const phaseCards: Array<{
|
const plan = detail.data
|
||||||
value: KhkkPhaseValue | null
|
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
|
||||||
label: string
|
const readOnly =
|
||||||
icon: ReactNode
|
!plan
|
||||||
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
|| !(plan.phase === KhkkPhase.DangSoanThao || plan.phase === KhkkPhase.TraLai)
|
||||||
count: number
|
|| (!!user?.id && plan.drafterUserId !== user.id)
|
||||||
}> = [
|
|
||||||
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
|
const headerTitle = deletedView
|
||||||
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
|
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
|
||||||
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
|
: group !== null ? `Kế hoạch ký kết HĐ — N${group}` : 'Kế hoạch ký kết HĐ'
|
||||||
{ 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] },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
<PageHeader
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
eyebrow="Giai đoạn 2"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
title={
|
<FileCheck className="h-5 w-5 text-slate-500" />
|
||||||
deletedView
|
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
|
<span className="ml-2 truncate rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
: group !== null
|
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''}
|
||||||
? `Kế hoạch ký kết HĐ — N${group}`
|
</span>
|
||||||
: '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`).
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* [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)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!deletedView && (
|
||||||
|
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
|
||||||
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Search className="h-4 w-4 shrink-0 text-slate-400" />
|
Lập kế hoạch
|
||||||
<Input
|
</Button>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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">Mã 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>
|
</header>
|
||||||
</div>
|
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
{selectedId && plan && (
|
||||||
|
<KhkkWorkflowPanel
|
||||||
|
plan={plan}
|
||||||
|
onChanged={() => detail.refetch()}
|
||||||
|
onDeleted={() => setId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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">
|
|
||||||
✓ ký {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
|
|
||||||
“Kế hoạch ký kết HĐ” 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 “Gửi duyệt”, 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 “Chờ duyệt” và 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 “Trả lại”. 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 “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
|
||||||
thì ghi đè ô cũ).
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -103,6 +103,10 @@ export const DOSSIER_STATUS_BADGE: Record<DossierItemStatusValue, string> = {
|
|||||||
export const KhkkAttachmentPurpose = {
|
export const KhkkAttachmentPurpose = {
|
||||||
DossierScan: 1,
|
DossierScan: 1,
|
||||||
PlanDocument: 2,
|
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,
|
Other: 99,
|
||||||
} as const
|
} as const
|
||||||
export type KhkkAttachmentPurposeValue =
|
export type KhkkAttachmentPurposeValue =
|
||||||
@ -111,6 +115,7 @@ export type KhkkAttachmentPurposeValue =
|
|||||||
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
|
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
|
||||||
1: 'Scan căn cứ',
|
1: 'Scan căn cứ',
|
||||||
2: 'Bản kế hoạch',
|
2: 'Bản kế hoạch',
|
||||||
|
3: 'File duyệt',
|
||||||
99: 'Khác',
|
99: 'Khác',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,13 +277,88 @@ export interface KhkkWorkflowLevelDto {
|
|||||||
allowApproverFinalize: boolean
|
allowApproverFinalize: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
|
// Mirror `ContractSigningPlanWorkflowStepDto`.
|
||||||
export interface KhkkWorkflowStepDto {
|
export interface KhkkWorkflowStepDto {
|
||||||
id: string
|
id: string
|
||||||
order: number
|
order: number
|
||||||
name: string
|
name: string
|
||||||
departmentId: string | null
|
departmentId: string | null
|
||||||
levels: KhkkWorkflowLevelDto[]
|
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 {
|
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
|
// [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
|
// (`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.
|
// `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
|
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
|
// [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
|
||||||
|
|||||||
@ -81,6 +81,19 @@ public class ContractSigningPlansController(
|
|||||||
public async Task<ActionResult<ContractSigningPlanDetailDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<ContractSigningPlanDetailDto>> Get(Guid id, CancellationToken ct)
|
||||||
=> Ok(await mediator.Send(new GetContractSigningPlanQuery(id), 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 =========================
|
// ========================= Ghi header =========================
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@ -179,6 +192,33 @@ public class ContractSigningPlansController(
|
|||||||
return Ok(dto);
|
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")]
|
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||||
[Authorize(Policy = "KeHoachKyKet.Read")]
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
|||||||
@ -140,7 +140,32 @@ public record ContractSigningPlanWorkflowStepDto(
|
|||||||
int Order,
|
int Order,
|
||||||
string Name,
|
string Name,
|
||||||
Guid? DepartmentId,
|
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(
|
public record ContractSigningPlanDetailDto(
|
||||||
Guid Id,
|
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,
|
// + 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
|
// 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`).
|
// 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".
|
/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
|
||||||
public record ApprovedPeAwaitingPlanDto(
|
public record ApprovedPeAwaitingPlanDto(
|
||||||
@ -702,8 +741,12 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
|||||||
|
|
||||||
// ---- Workflow tree + metadata Cấp cho LevelOpinions.
|
// ---- Workflow tree + metadata Cấp cho LevelOpinions.
|
||||||
string? wfCode = null, wfName = null;
|
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 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)
|
if (plan.ApprovalWorkflowId is Guid wfId)
|
||||||
{
|
{
|
||||||
@ -715,6 +758,7 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
|||||||
{
|
{
|
||||||
wfCode = wf.Code;
|
wfCode = wf.Code;
|
||||||
wfName = wf.Name;
|
wfName = wf.Name;
|
||||||
|
wfVersion = wf.Version;
|
||||||
var approverIds = wf.Steps.SelectMany(s => s.Levels).Select(l => l.ApproverUserId)
|
var approverIds = wf.Steps.SelectMany(s => s.Levels).Select(l => l.ApproverUserId)
|
||||||
.Distinct().ToList();
|
.Distinct().ToList();
|
||||||
var approverNames = await db.Users.AsNoTracking()
|
var approverNames = await db.Users.AsNoTracking()
|
||||||
@ -722,18 +766,64 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
|||||||
.Select(u => new { u.Id, u.FullName })
|
.Select(u => new { u.Id, u.FullName })
|
||||||
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
.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(
|
stepDtos.Add(new ContractSigningPlanWorkflowStepDto(
|
||||||
s.Id, s.Order, s.Name, s.DepartmentId,
|
s.Id, s.Order, s.Name, s.DepartmentId,
|
||||||
s.Levels.OrderBy(l => l.Order).Select(l => new ContractSigningPlanWorkflowLevelDto(
|
s.Levels.OrderBy(l => l.Order).Select(l => new ContractSigningPlanWorkflowLevelDto(
|
||||||
l.Id, l.Order, l.Name, l.ApproverUserId,
|
l.Id, l.Order, l.Name, l.ApproverUserId,
|
||||||
approverNames.TryGetValue(l.ApproverUserId, out var fn) ? fn : null,
|
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)
|
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)
|
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||||
.ToList();
|
.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(
|
return new ContractSigningPlanDetailDto(
|
||||||
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
|
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
|
||||||
plan.ProjectId, project?.Name, project?.Code,
|
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
|
// được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại choke-point finalize
|
||||||
// (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
|
// (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
|
||||||
// bảo FE "đọc `ApprovedAmount` + khoá ô" thay vì đọc `ProposedAmount`.
|
// 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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
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
|
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,
|
Other = 99,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user