[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s

Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 17:05:27 +07:00
parent 89b5bbee8a
commit 0779f2d45a
23 changed files with 8905 additions and 109 deletions

View File

@ -2,14 +2,20 @@
// Khuôn `pages/office/ProposalCreatePage.tsx` (PURO: ui/PageHeader + section
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
//
// 3 section: (1) chọn phiếu PE nguồn — chỉ phiếu DaDuyet CHƯA có kế hoạch sống
// (BE lọc, FE không tự đoán); (2) quy trình duyệt V2 ApplicableType=10 admin đã
// ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
//
// [K2 S164 — OG-7 UI-hướng-1] ĐỔI HÌNH DẠNG màn này: trước K2, PE đã có kế hoạch bị
// BE loại khỏi picker (biến mất) và tạo trùng thì ăn 409 chặn hẳn. Từ K2, rào là
// "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm danh sách nhóm đã dùng + nút phụ
// "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). Ẩn PE đi thì người dùng
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { CheckCircle2, FileCheck, FileText, Save, Settings2, X } from 'lucide-react'
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
@ -18,7 +24,13 @@ import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { CreateKhkkInput, KhkkPeCandidateDto } from '@/types/khkk'
import {
KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS,
type CreateKhkkInput,
type KhkkPeCandidateDto,
} from '@/types/khkk'
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1).
@ -36,14 +48,23 @@ function formatDate(iso: string): string {
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
// một nhóm chắc chắn 409.
function firstFreeGroup(taken: number[]): number | null {
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
}
export function KhkkCreatePage() {
const navigate = useNavigate()
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('')
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ chưa có kế hoạch sống (BE lọc,
// mirror `ListApprovedPurchaseEvaluationsQuery` + vị ngữ chưa-có-plan).
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
const candidates = useQuery({
queryKey: ['khkk-pe-candidates'],
queryFn: async () =>
@ -73,6 +94,9 @@ export function KhkkCreatePage() {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId,
ghiChu: ghiChu.trim() || null,
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
approvalGroup,
}
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data
@ -91,6 +115,24 @@ export function KhkkCreatePage() {
const list = candidates.data ?? []
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
// cũ còn nằm trong cache React Query trước khi K2 deploy.
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup)
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
const selectPe = (pe: KhkkPeCandidateDto) => {
setPurchaseEvaluationId(pe.id)
const taken = pe.existingGroups ?? []
if (taken.includes(approvalGroup)) {
const free = firstFreeGroup(taken)
if (free !== null) setApprovalGroup(free)
}
}
return (
<div className="space-y-5">
<PageHeader
@ -126,17 +168,21 @@ export function KhkkCreatePage() {
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
Không phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch kết.
Không phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu.
</div>
)}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<button
key={pe.id}
type="button"
onClick={() => setPurchaseEvaluationId(pe.id)}
onClick={() => selectPe(pe)}
className={cn(
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
selected
@ -172,6 +218,33 @@ export function KhkkCreatePage() {
</span>
</span>
</button>
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng
button là HTML không hợp lệ). */}
{taken.length > 0 && (
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs">
<span className="text-slate-500">Đã kế hoạch nhóm:</span>
{taken.map((g) => (
<span
key={g}
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
>
N{g}
</span>
))}
{free === null ? (
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span>
) : (
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Tạo thêm cho nhóm khác
</Button>
)}
</div>
)}
</div>
)
})}
</div>
@ -188,27 +261,64 @@ export function KhkkCreatePage() {
>
<Settings2 className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Nhóm duyệt & quy trình</h3>
</header>
<div className="space-y-2 p-5">
<Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span>
</Label>
<select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value=""> Chọn quy trình </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</select>
<div className="text-xs text-muted-foreground mt-1">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
<div className="space-y-2">
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-violet-600" />
Nhóm duyệt (N1N8) <span className="text-red-500">*</span>
</Label>
<select
id="approvalGroup"
value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
return (
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</select>
{groupTaken ? (
<div className="mt-1 text-xs font-medium text-red-600">
Phiếu Duyệt NCC này đã kế hoạch kết cho nhóm N{approvalGroup} chọn nhóm khác.
</div>
) : (
<div className="text-xs text-muted-foreground mt-1">
Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span>
</Label>
<select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value=""> Chọn quy trình </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</select>
<div className="text-xs text-muted-foreground mt-1">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</div>
</div>
</div>
</section>
@ -243,7 +353,12 @@ export function KhkkCreatePage() {
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId}>
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */}
<Button
type="submit"
disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId || groupTaken}
>
<Save className="mr-2 h-4 w-4" />
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
</Button>

View File

@ -26,7 +26,8 @@ import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus, KHKK_PHASE_BADGE, KHKK_PHASE_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,
@ -290,6 +291,18 @@ export function KhkkDetailPage() {
accent="brand"
actions={
<>
{/* [K2 S164] Badge NHÓM DUYỆT — đứng TRƯỚC badge phase vì nhóm là thứ quyết
phiếu đi đường nào (8 quy trình khác nhau), phase chỉ là chỗ đang đứng.
`!= null` guard: phiếu nạp từ cache trước K2 chưa có field ⇒ đừng in
"Nundefined" (bài "vắng-mặt trông giống ổn"). */}
{k.approvalGroup != null && (
<span
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2.5 py-1 text-xs font-semibold text-violet-800"
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
>
N{k.approvalGroup}
</span>
)}
<span
className={cn(
'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
@ -423,6 +436,16 @@ export function KhkkDetailPage() {
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={k.lines.length}
actions={
// [K2 S164 — QĐ-9] `budgetFrozen` = cờ HIỂN THỊ do BE dẫn xuất từ phase
// (`DetailDto:166`). Số đã đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=`
// tại choke-point finalize — badge này chỉ nói cho người dùng biết điều đó.
k.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
}
>
{k.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div>
@ -431,6 +454,10 @@ export function KhkkDetailPage() {
<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>
{/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này.
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng
nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */}
<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 (phiếu NCC)</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>
@ -441,6 +468,9 @@ export function KhkkDetailPage() {
{k.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 ?? '—'}</td>
<td className="px-3 py-2 text-xs text-slate-700">
{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>
@ -452,6 +482,7 @@ export function KhkkDetailPage() {
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu SNAPSHOT tổng báo giá đưc chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
{' '}Cột Hạng mục hiện chưa gán đưc từ màn này (ô gán thuộc bước Lines-editor).
</div>
</SectionCard>

View File

@ -25,6 +25,7 @@ import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUP_LABELS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
@ -134,7 +135,7 @@ export function KhkkListPage() {
{/* [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
7 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
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} />
@ -172,7 +173,7 @@ export function KhkkListPage() {
</div>
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì
{/* [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">
@ -183,6 +184,10 @@ export function KhkkListPage() {
<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>
@ -190,14 +195,14 @@ export function KhkkListPage() {
<tbody>
{list.isLoading && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
<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={7} className="px-4 py-10 text-center text-slate-500">
<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' }}
@ -228,6 +233,18 @@ export function KhkkListPage() {
<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">
@ -249,6 +266,11 @@ export function KhkkListPage() {
)}
</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>

View File

@ -24,6 +24,17 @@
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
// policy `KeHoachKyKet.Update`
//
// [K2 S164] Delta hợp-đồng lượt K2 (BE đã land — đọc `ContractSigningPlanFeatures.cs`):
// GET /contract-signing-plans **+`?group=n`** (`Controller:42` `[FromQuery(Name="group")]`
// → `ListContractSigningPlansQuery.ApprovalGroup` `:700-705`). K2 = CHỦ tham số;
// cây 8 folder (K5) + leaf nhóm (K4b) chỉ TIÊU THỤ ⇒ FE list KHÔNG tự đẻ chỗ lọc
// thứ hai (chờ K4b/K5), chỉ HIỂN THỊ nhóm của từng phiếu.
// POST /contract-signing-plans body **+`approvalGroup?`** (`CreateCommand:333-337`,
// null ⇒ nhóm 1 — CỐ Ý nullable để client cũ không gãy).
// GET /contract-signing-plans/approved-pe-awaiting-plan → **ĐỔI HÌNH DẠNG**: PE đã có
// kế hoạch KHÔNG còn bị loại khỏi danh sách (`Features.cs:1291-1295`), thay vào đó
// trả `existingGroups` để FE khoá đúng nhóm đã dùng (OG-7 UI-hướng-1).
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
export const KhkkPhase = {
@ -103,6 +114,29 @@ export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue,
99: 'Khác',
}
// ===================== K2 S164 — Nhóm duyệt N1..N8 (SP-002) ==========================
// Mirror `ContractSigningPlanScope.cs` (`ContractSigningPlanFeatures.cs:201-203`):
// Min=1 · Max=8 · Default=1. Không gửi nhóm ⇒ BE gán 1 (khớp backfill Mig 71).
export const KHKK_APPROVAL_GROUP_MIN = 1
export const KHKK_APPROVAL_GROUP_MAX = 8
export const KHKK_APPROVAL_GROUP_DEFAULT = 1
export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
// 🔴 NHÃN NGHIỆP VỤ 8 nhóm — nguồn `danh-muc-sp002-transcribe.md:20-29` ("8 NHÃN MENU
// đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu
// ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page.
// Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}").
export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
1: 'Thiết bị, máy móc, vật tư phụ (A1)',
2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)',
3: 'Hoạt động văn phòng (A4)',
4: 'Vật tư (B1)',
5: 'Thầu phụ xây dựng (B2)',
6: 'Thầu phụ MEP (B3)',
7: 'BCH công trường (C1-C3)',
8: 'HĐ chưa ủy quyền — CEO (5.2)',
}
export interface KhkkListItemDto {
id: string
maKeHoach: string | null
@ -120,6 +154,14 @@ export interface KhkkListItemDto {
workflowCode: string | null
currentApprovalLevelOrder: number | null
totalProposedAmount: number | null
// [K2 S164] `ContractSigningPlanListItemDto:63` — nhóm duyệt N1..N8 của phiếu (BE trả
// TRÊN CẢ list lẫn detail ⇒ `KhkkDetailDto` kế thừa được, không khai lại).
approvalGroup: number
// [K2 S164 — vá-8] `:64` Σ `ApprovedAmount` (phiếu chưa duyệt → 0). Có Σ này thì màn
// danh sách và màn chi tiết không nói hai con số khác nhau trên CÙNG một phiếu.
// 🔴 nullable Ở ĐÂY (không phải ở BE): `KhkkDetailDto extends KhkkListItemDto` mà
// `ContractSigningPlanDetailDto` KHÔNG có field này ⇒ trên màn chi tiết nó là undefined.
totalApprovedAmount: number | null
createdAt: string
updatedAt: string | null
}
@ -133,6 +175,12 @@ export interface KhkkLineDto {
approvedAmount: number | null
contractId: string | null
note: string | null
// [K2 S164] `ContractSigningPlanLineDto:78-79` — hạng mục SP-002 của dòng.
// `tenHangMuc` là DENORM lúc gán (danh mục đổi tên KHÔNG viết lại lịch sử phiếu).
// null = dòng chưa gán hạng mục (mồi lúc auto-sinh, `Features.cs:454-455`) — màn chi
// tiết hiện "—"; ĐƯỜNG GÁN (Lines-editor) thuộc **K4b**, K2 chỉ hiển thị.
catalogEntryId: string | null
tenHangMuc: string | null
}
export interface KhkkDossierItemDto {
@ -206,10 +254,18 @@ export interface KhkkDetailDto extends KhkkListItemDto {
levelOpinions: KhkkLevelOpinionDto[]
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
workflowSteps: KhkkWorkflowStepDto[]
// [K2 S164] `ContractSigningPlanDetailDto:166` — CỜ HIỂN THỊ dẫn xuất từ phase
// (`phase == DaDuyet`), KHÔNG phải cột DB. Số đã đóng băng ở tầng dữ liệu bởi
// `ApprovedAmount ??=` tại choke-point finalize. Khuôn tên `budgetFrozen` của PE S133.
// 🔸 BE **KHÔNG** expose `endedByLevelFinalize` ở DTO lượt này (cột Mig 71 nằm im chờ
// K3 tiêu thụ — ghost-window đã khai ở `sub-implbe-k2.md §a`) ⇒ FE không khai field đó.
budgetFrozen: boolean
}
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
// CỐ Ý là TẬP CON của `PurchaseEvaluationListItemDto` BE trả — field thừa bỏ qua runtime.
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
// PE đã có kế hoạch VẪN hiện, kèm `existingGroups`.
export interface KhkkPeCandidateDto {
id: string
maPhieu: string | null
@ -218,6 +274,11 @@ export interface KhkkPeCandidateDto {
workItemName: string | null
winnerSupplierNames: string[]
createdAt: string
// [K2 S164] `ApprovedPeAwaitingPlanDto:191` — nhóm duyệt của các kế hoạch ĐANG SỐNG
// trên PE này (`Phase != TuChoi`). Rỗng = PE chưa có kế hoạch nào. FE dùng để (a) hiện
// nút "Tạo thêm cho nhóm khác" và (b) khoá đúng nhóm đã dùng trong ô chọn nhóm —
// rào cứng 409 vẫn nằm ở BE (`:395-402`), FE chỉ có nhiệm vụ không mời bấm nhầm.
existingGroups: number[]
}
export interface CreateKhkkInput {
@ -226,6 +287,10 @@ export interface CreateKhkkInput {
peId: string
approvalWorkflowId: string
ghiChu?: string | null
// [K2 S164] Nhóm duyệt N1..N8 (`CreateCommand:337` `int? ApprovalGroup = null`).
// Bỏ trống ⇒ BE gán nhóm 1. FE LUÔN gửi (ô chọn có default 1) — gửi tường minh để
// người dùng thấy đúng thứ mình chọn, không phụ thuộc default ngầm của BE.
approvalGroup?: number | null
}
export interface UpdateKhkkDraftInput {