[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
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:
@ -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 có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký kết.
|
||||
Không có 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">Đã có 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 (N1–N8) <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 đã có kế hoạch ký 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>
|
||||
|
||||
@ -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 có 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 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 hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor).
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user