[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s

- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*)
- FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list
- Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau)
- tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 21:19:41 +07:00
parent 914c5a40f2
commit 6cbc6ad937
20 changed files with 5020 additions and 23 deletions

View File

@ -31,6 +31,9 @@ import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
import { KhkkListPage } from '@/pages/khkk/KhkkListPage'
import { KhkkCreatePage } from '@/pages/khkk/KhkkCreatePage'
import { KhkkDetailPage } from '@/pages/khkk/KhkkDetailPage'
import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
@ -95,6 +98,12 @@ function App() {
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}
<Route path="/meeting-calendar" element={<MeetingCalendarPage />} />
<Route path="/meeting-rooms" element={<MeetingRoomsPage />} />
{/* [W2 KHKK S161] Kế hoạch ký kết HĐ (GĐ2). KHÔNG route matrix bên admin —
Khkk_WfView admin trỏ Designer type-10 (Layout.tsx staticMap). */}
<Route path="/khkk" element={<Navigate to="/khkk/list" replace />} />
<Route path="/khkk/list" element={<KhkkListPage />} />
<Route path="/khkk/create" element={<KhkkCreatePage />} />
<Route path="/khkk/:id" element={<KhkkDetailPage />} />
<Route path="/proposals" element={<ProposalsListPage />} />
<Route path="/proposals/new" element={<ProposalCreatePage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />

View File

@ -29,14 +29,15 @@ const TYPE_CODE_TO_INT: Record<string, number> = {
// Ct_<Type>_<Action> sub-menu entries.
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
// [S159-đợt3] skeleton GĐ2/GĐ4 — leaf trỏ trang "Sắp triển khai" (query giữ
// active-state riêng từng mục; gotcha #50: thiếu route = sidebar drop SILENT).
Khkk_WfView: '/coming-soon?stage=2&m=wfview',
Khkk_List: '/coming-soon?stage=2&m=list',
Khkk_Create: '/coming-soon?stage=2&m=create',
Khkk_Pending: '/coming-soon?stage=2&m=pending',
Khkk_Approved: '/coming-soon?stage=2&m=approved',
Khkk_Deleted: '/coming-soon?stage=2&m=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50)
// [W2 KHKK S161] GĐ2 hết skeleton — re-point trang thật (gotcha #50: thiếu route =
// sidebar drop SILENT). WfView phía admin trỏ Designer type-10 (fe-admin CỐ Ý không có
// matrix page — chốt 2026-07-27; admin "Luồng duyệt" = dựng/sửa quy trình).
Khkk_WfView: '/system/approval-workflows-v2/ContractSigningPlan',
Khkk_List: '/khkk/list',
Khkk_Create: '/khkk/create',
Khkk_Pending: '/khkk/list?filter=ChoDuyet',
Khkk_Approved: '/khkk/list?filter=DaDuyet',
Khkk_Deleted: '/khkk/list?view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50)
Hdc_ThauPhu: '/coming-soon?stage=4&t=01',
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',

View File

@ -0,0 +1,254 @@
// [W2 KHKK — S161 2026-07-29] Lập "Kế hoạch ký kết HĐ" từ phiếu Duyệt NCC đã duyệt.
// 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
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
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 { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
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 type { CreateKhkkInput, 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).
const APPLICABLE_TYPE_KHKK = 10
interface WorkflowDto {
id: string
code: string
name: string
isUserSelectable: boolean
}
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 KhkkCreatePage() {
const navigate = useNavigate()
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
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).
const candidates = useQuery({
queryKey: ['khkk-pe-candidates'],
queryFn: async () =>
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
})
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array (khuôn ProposalCreatePage:59-69). Lọc isUserSelectable =
// quy trình admin đã ghim cho user pick.
const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: WorkflowDto[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: APPLICABLE_TYPE_KHKK } },
)
const bucket = res.data.types.find((t) => t.applicableType === APPLICABLE_TYPE_KHKK)
return (bucket?.history ?? []).filter((w) => w.isUserSelectable)
},
})
const create = useMutation({
mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
if (!approvalWorkflowId) throw new Error('Vui lòng chọn Quy trình duyệt')
const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId,
ghiChu: ghiChu.trim() || null,
}
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data
},
onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
create.mutate()
}
const list = candidates.data ?? []
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title="Lập kế hoạch ký kết HĐ"
subtitle="Chọn phiếu Duyệt NCC đã duyệt → hệ thống chép dòng giá của từng NCC trúng thầu"
icon={<FileCheck className="h-5 w-5" />}
accent="brand"
actions={
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<X className="mr-2 h-4 w-4" />
Huỷ
</Button>
}
/>
<form onSubmit={onSubmit} className="space-y-5">
{/* Section 1: Phiếu Duyệt NCC nguồn */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-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-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<FileText className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-brand-800">
Phiếu Duyệt NCC nguồn <span className="text-red-500">*</span>
</h3>
</header>
<div className="space-y-2 p-5">
{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 chưa lập kế hoạch kết.
</div>
)}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
return (
<button
key={pe.id}
type="button"
onClick={() => setPurchaseEvaluationId(pe.id)}
className={cn(
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
selected
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-300'
: 'border-slate-200 hover:border-brand-300 hover:bg-slate-50',
)}
>
<CheckCircle2
className={cn('mt-0.5 h-4 w-4 shrink-0', selected ? 'text-brand-600' : 'text-slate-300')}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="font-medium text-brand-800">{pe.tenGoiThau}</span>
</span>
<span className="mt-0.5 block text-xs text-slate-500">
{pe.projectName ?? '—'}
{pe.workItemName ? ` · ${pe.workItemName}` : ''} · {formatDate(pe.createdAt)}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{pe.winnerSupplierNames.length === 0 ? (
<span className="text-xs italic text-slate-400">Chưa NCC trúng thầu</span>
) : (
pe.winnerSupplierNames.map((n) => (
<span
key={n}
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
>
{n}
</span>
))
)}
</span>
</span>
</button>
)
})}
</div>
</div>
</section>
{/* Section 2: Quy trình duyệt */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-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-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
aria-hidden
>
<Settings2 className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</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>
</div>
</section>
{/* Section 3: Ghi chú */}
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-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-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }}
aria-hidden
>
<FileText className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Ghi chú</h3>
</header>
<div className="space-y-2 p-5">
<Label htmlFor="ghiChu">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="text-xs text-muted-foreground mt-1">{ghiChu.length}/2000</div>
</div>
</section>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId}>
<Save className="mr-2 h-4 w-4" />
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
</Button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,680 @@
// [W2 KHKK — S161 2026-07-29] Chi tiết "Kế hoạch ký kết HĐ" (GĐ2).
// Khuôn `pages/office/ProposalDetailPage.tsx` (SectionCard + Field idiom PURO).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 4 section: (1) Thông tin + ghi chú/link hồ sơ sửa được khi nháp · (2) Dòng giá
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
// đính kèm (mở MỌI phase — triết lý PE S147).
// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3.
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
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_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): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN')
}
function formatSize(b: number): string {
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
}
// Section card — .card-accent shell + .icon-chip header (Hồ sơ-NS idiom).
function SectionCard({
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="card-accent" style={{ ['--accent' as string]: accent }}>
<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]: chipBg, ['--chip-fg' as string]: chipFg }}
aria-hidden
>
{icon}
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', head)}>{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>
<div className="p-5">{children}</div>
</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 KhkkDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const [editHeader, setEditHeader] = useState(false)
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
const plan = useQuery({
queryKey: ['khkk-detail', id],
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${id}`)).data,
enabled: !!id,
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['khkk-detail', id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
}
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)),
})
const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
// [F-3 S161] BE verify id-path == cmd.ContractSigningPlanId. [F-4] POST ép Id=null
// = LUÔN insert ⇒ bấm "Sửa" phải đi PUT, không thì đẻ bản sao im lặng.
const payload = {
...body,
contractSigningPlanId: id,
name: body.name.trim(),
tvgsName: body.tvgsName?.trim() || null,
note: body.note?.trim() || null,
}
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)),
})
const deletePlan = useMutation({
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
onSuccess: () => {
toast.success('Đã xóa kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-list'] })
navigate('/khkk/list')
},
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 */ }
}
}
if (plan.isLoading) {
return (
<div className="space-y-5">
<PageHeader eyebrow="Giai đoạn 2" title="Đang tải..." icon={<FileCheck className="h-5 w-5" />} />
</div>
)
}
if (plan.isError || !plan.data) {
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title="Lỗi"
icon={<FileCheck className="h-5 w-5" />}
actions={
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
}
/>
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
Không tải đưc dữ liệu kế hoạch kết.
</div>
</div>
)
}
const k = plan.data
const phase = k.phase as KhkkPhaseValue
const isDraft = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
const startEditHeader = () => {
setGhiChu(k.ghiChu ?? '')
setHoSoLink(k.hoSoLink ?? '')
setEditHeader(true)
}
return (
<div className="space-y-5">
<PageHeader
eyebrow={k.maKeHoach ?? 'Kế hoạch ký kết'}
title={k.peTenGoiThau ?? 'Kế hoạch ký kết HĐ'}
icon={<FileCheck className="h-5 w-5" />}
accent="brand"
actions={
<>
<span
className={cn(
'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
KHKK_PHASE_BADGE[phase],
)}
>
{KHKK_PHASE_LABELS[phase]}
</span>
{canDeletePlan && (
<Button
variant="outline"
onClick={() => {
if (window.confirm('Xóa kế hoạch ký kết này?')) deletePlan.mutate()
}}
disabled={deletePlan.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
)}
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Danh sách
</Button>
</>
}
/>
{/* Section 1: Thông tin */}
<SectionCard
title="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={k.maKeHoach ?? '—'} mono />
<Field label="Phiếu Duyệt NCC nguồn">
<span className="font-mono text-sm">{k.peMaPhieu ?? '—'}</span>
</Field>
<Field label="Dự án" value={k.projectName ?? '—'} />
<Field label="Phòng ban" value={k.departmentName ?? '—'} />
<Field label="Người soạn" value={k.drafterFullName ?? '—'} />
<Field label="Quy trình">
{k.workflowCode ? (
<span>
<span className="font-mono">{k.workflowCode}</span> - {k.workflowName}
</span>
) : (
<span className="text-slate-300"> Chưa chọn </span>
)}
</Field>
<Field label="Ngày tạo" value={formatDateTime(k.createdAt)} />
<Field label="Hạn xử lý (tham khảo)" value={k.slaDeadline ? formatDateTime(k.slaDeadline) : '—'} />
</div>
{editHeader ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
<div>
<Label htmlFor="hoSoLink">Link hồ (NAS)</Label>
<Input
id="hoSoLink"
value={hoSoLink}
onChange={(e) => setHoSoLink(e.target.value)}
maxLength={1000}
placeholder="\\\\nas\\du-an\\..."
/>
</div>
<div>
<Label htmlFor="ghiChu">Ghi chú</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
/>
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>
Huỷ
</Button>
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
<Save className="mr-2 h-3.5 w-3.5" />
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Link hồ sơ" full>
{k.hoSoLink ? (
<span className="break-all font-normal text-slate-700">{k.hoSoLink}</span>
) : (
<span className="text-slate-300"></span>
)}
</Field>
<Field label="Ghi chú" full>
{k.ghiChu ? (
<span className="whitespace-pre-wrap font-normal text-slate-700">{k.ghiChu}</span>
) : (
<span className="text-slate-300"></span>
)}
</Field>
</div>
)}
</SectionCard>
{/* Section 2: Dòng giá per-NCC trúng thầu */}
<SectionCard
title="Giá đề xuất theo NCC trúng thầu"
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)"
count={k.lines.length}
>
{k.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
<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-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>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
</tr>
</thead>
<tbody>
{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-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 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.
</div>
</SectionCard>
{/* Section 3: Căn cứ hồ sơ b.8-9 */}
<SectionCard
title="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={k.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
}
>
{k.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div>
) : (
<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>
{k.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>
)}
</SectionCard>
{/* Section 4: File đính kèm — mở mọi phase */}
<SectionCard
title="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={k.attachments.length}
actions={
<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>
}
>
{k.attachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : (
<ul className="space-y-2">
{k.attachments.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>
<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>
)}
</SectionCard>
{/* Dialog thêm/sửa căn cứ */}
<Dialog
open={!!itemForm}
onClose={() => setItemForm(null)}
title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}
>
{itemForm && (
<div className="space-y-3">
<div>
<Label htmlFor="item-kind">Loại căn cứ</Label>
<select
id="item-kind"
value={itemForm.kind}
onChange={(e) => setItemForm({ ...itemForm, kind: Number(e.target.value) as DossierItemKindValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_KIND_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-name">
Tên căn cứ <span className="text-red-500">*</span>
</Label>
<Input
id="item-name"
value={itemForm.name}
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
maxLength={300}
placeholder="vd. Mẫu thép D10 Hoà Phát"
/>
</div>
<div>
<Label htmlFor="item-status">Trạng thái</Label>
<select
id="item-status"
value={itemForm.status}
onChange={(e) => setItemForm({ ...itemForm, status: Number(e.target.value) as DossierItemStatusValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_STATUS_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-tvgs">Tên TVGS xử </Label>
<Input
id="item-tvgs"
value={itemForm.tvgsName ?? ''}
onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })}
maxLength={200}
/>
</div>
<div>
<Label htmlFor="item-note">Ghi chú</Label>
<Textarea
id="item-note"
value={itemForm.note ?? ''}
onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })}
maxLength={1000}
rows={3}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setItemForm(null)}>
Huỷ
</Button>
<Button
onClick={() => upsertItem.mutate(itemForm)}
disabled={upsertItem.isPending || !itemForm.name.trim()}
>
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
)}
</Dialog>
</div>
)
}

View File

@ -0,0 +1,265 @@
// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2).
// Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
// fe-admin counterpart.
//
// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis):
// Khkk_List → /khkk/list
// Khkk_Pending → /khkk/list?filter=ChoDuyet
// Khkk_Approved → /khkk/list?filter=DaDuyet
// Khkk_Deleted → /khkk/list?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).
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Plus, Search, FileCheck, FileEdit, SendHorizontal,
CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
} from '@/types/khkk'
const PAGE_SIZE = 20
// Tên phase trong query `?filter=` → int (URL đọc được, không dính số ma thuật).
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
DangSoanThao: KhkkPhase.DangSoanThao,
ChoDuyet: KhkkPhase.ChoDuyet,
DaDuyet: KhkkPhase.DaDuyet,
TraLai: KhkkPhase.TraLai,
TuChoi: KhkkPhase.TuChoi,
}
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function KhkkListPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const initialFilter = searchParams.get('filter')
const deletedView = searchParams.get('view') === 'deleted'
const [phase, setPhase] = useState<KhkkPhaseValue | null>(
initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null,
)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const list = useQuery({
queryKey: ['khkk-list', { 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),
search: search.trim() || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
// Presentation-only: đếm trên trang đang tải (không fetch thêm). Card đang active
// hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh.
const countByPhase = useMemo(() => {
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 }
for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1
return acc
}, [items])
const phaseCards: Array<{
value: KhkkPhaseValue | null
label: string
icon: ReactNode
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
count: number
}> = [
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
{ value: KhkkPhase.DaDuyet, label: KHKK_PHASE_LABELS[3], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: phase === 3 ? total : countByPhase[3] },
{ value: KhkkPhase.TraLai, label: KHKK_PHASE_LABELS[98], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: phase === 98 ? total : countByPhase[98] },
{ value: KhkkPhase.TuChoi, label: KHKK_PHASE_LABELS[99], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: phase === 99 ? total : countByPhase[99] },
]
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title={deletedView ? 'Kế hoạch ký kết HĐ — đã xóa' : 'Kế hoạch ký kết HĐ'}
subtitle={
deletedView
? 'Phiếu đã xóa mềm — chỉ xem danh sách'
: '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 : (
<Button onClick={() => navigate('/khkk/create')}>
<Plus className="mr-2 h-4 w-4" />
Lập kế hoạch
</Button>
)
}
/>
{!deletedView && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg: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={() => {
setPhase(c.value)
setPage(1)
}}
/>
))}
</div>
)}
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
placeholder="Tìm mã kế hoạch, mã phiếu hoặc tên gói thầu..."
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
/>
</div>
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-4 py-2.5 text-left"> kế hoạch</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đ xuất</th>
<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={7} 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">
<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>
</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>
<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>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-slate-500">
{total} kế hoạch Trang {page} / {totalPages}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Sau
</Button>
</div>
</div>
)}
</div>
</div>
)
}

229
fe-admin/src/types/khkk.ts Normal file
View File

@ -0,0 +1,229 @@
// [W2 KHKK — S161 2026-07-29] Kế hoạch ký kết HĐ (GĐ2, tờ FO-002.01) types.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 NGUỒN SHAPE = ENTITY `src/Backend/SolutionErp.Domain/ContractSigningPlans/*.cs`
// (W1 Mig 69) — BE lane viết `ContractSigningPlanFeatures.cs` SONG SONG lượt này, DTO
// mirror thẳng tên cột entity (camelCase khi serialize). Chỗ nào BE trả khác tên thì
// SỬA Ở ĐÂY (1 file, 2 app) chứ đừng vá rải ở page.
//
// Hợp đồng FE ↔ BE lượt W2 (base `/api`):
// GET /contract-signing-plans ?phase&search&page&pageSize → PagedResult<KhkkListItemDto>
// GET /contract-signing-plans/deleted (mirror PE `/purchase-evaluations/deleted`)
// GET /contract-signing-plans/{id} → KhkkDetailDto
// POST /contract-signing-plans CreateKhkkInput → { id, maKeHoach }
// PUT /contract-signing-plans/{id} UpdateKhkkDraftInput
// DELETE /contract-signing-plans/{id}
// GET /contract-signing-plans/approved-pe-awaiting-plan → KhkkPeCandidateDto[] [F-1 S161: khớp route BE thật]
// POST /contract-signing-plans/{id}/dossier-items UpsertKhkkDossierItemInput (id=null ⇒ tạo)
// PUT /contract-signing-plans/{id}/dossier-items/{itemId} UpsertKhkkDossierItemInput (sửa — F-4: POST luôn insert)
// DELETE /contract-signing-plans/{id}/dossier-items/{itemId}
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
// GET /contract-signing-plans/{id}/attachments/{attId}/download
// DELETE /contract-signing-plans/{id}/attachments/{attId}
// (transitions submit/approve/return/reject = W3, CỐ Ý chưa có ở đây.)
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
export const KhkkPhase = {
DangSoanThao: 1,
ChoDuyet: 2,
DaDuyet: 3,
TraLai: 98,
TuChoi: 99,
} as const
export type KhkkPhaseValue = (typeof KhkkPhase)[keyof typeof KhkkPhase]
export const KHKK_PHASE_LABELS: Record<KhkkPhaseValue, string> = {
1: 'Đang soạn thảo',
2: 'Chờ duyệt',
3: 'Đã duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
// Pattern 14 (Tailwind JIT): full class string literal, KHÔNG nội suy mảnh class.
export const KHKK_PHASE_BADGE: Record<KhkkPhaseValue, string> = {
1: 'bg-slate-100 text-slate-700 border-slate-300',
2: 'bg-amber-100 text-amber-800 border-amber-300',
3: 'bg-emerald-100 text-emerald-800 border-emerald-300',
98: 'bg-orange-100 text-orange-800 border-orange-300',
99: 'bg-red-100 text-red-800 border-red-300',
}
// Mirror `ContractSigningPlanDossierItem.cs` — căn cứ mục b.8-9.
export const DossierItemKind = {
MauVatLieu: 1,
Shopdrawing: 2,
Khac: 99,
} as const
export type DossierItemKindValue = (typeof DossierItemKind)[keyof typeof DossierItemKind]
export const DOSSIER_KIND_LABELS: Record<DossierItemKindValue, string> = {
1: 'Mẫu vật liệu',
2: 'Shopdrawing',
99: 'Khác',
}
export const DossierItemStatus = {
ChuaNop: 1,
DaNop: 2,
TvgsDuyet: 3,
TvgsBac: 4,
} as const
export type DossierItemStatusValue = (typeof DossierItemStatus)[keyof typeof DossierItemStatus]
export const DOSSIER_STATUS_LABELS: Record<DossierItemStatusValue, string> = {
1: 'Chưa nộp',
2: 'Đã nộp',
3: 'TVGS duyệt',
4: 'TVGS bác',
}
export const DOSSIER_STATUS_BADGE: Record<DossierItemStatusValue, string> = {
1: 'bg-slate-100 text-slate-700 border-slate-300',
2: 'bg-sky-100 text-sky-800 border-sky-300',
3: 'bg-emerald-100 text-emerald-800 border-emerald-300',
4: 'bg-red-100 text-red-800 border-red-300',
}
// Mirror `ContractSigningPlanAttachmentPurpose`.
export const KhkkAttachmentPurpose = {
DossierScan: 1,
PlanDocument: 2,
Other: 99,
} as const
export type KhkkAttachmentPurposeValue =
(typeof KhkkAttachmentPurpose)[keyof typeof KhkkAttachmentPurpose]
export const KHKK_ATTACHMENT_PURPOSE_LABELS: Record<KhkkAttachmentPurposeValue, string> = {
1: 'Scan căn cứ',
2: 'Bản kế hoạch',
99: 'Khác',
}
export interface KhkkListItemDto {
id: string
maKeHoach: string | null
phase: KhkkPhaseValue
purchaseEvaluationId: string
peMaPhieu: string | null
peTenGoiThau: string | null // [F-5 S161] BE trả `PeTenGoiThau` (List :39 + Detail :124) — KHÔNG phải tenGoiThau
projectId: string
projectName: string | null
departmentId: string | null
departmentName: string | null
drafterUserId: string
drafterFullName: string | null
approvalWorkflowId: string | null
workflowCode: string | null
currentApprovalLevelOrder: number | null
totalProposedAmount: number | null
createdAt: string
updatedAt: string | null
}
export interface KhkkLineDto {
id: string
supplierId: string
supplierName: string | null
peReferenceAmount: number
proposedAmount: number
approvedAmount: number | null
contractId: string | null
note: string | null
}
export interface KhkkDossierItemDto {
id: string
kind: DossierItemKindValue
supplierId: string | null
supplierName: string | null
name: string
status: DossierItemStatusValue
tvgsName: string | null
tvgsResultAt: string | null
note: string | null
}
export interface KhkkAttachmentDto {
id: string
dossierItemId: string | null // [F-7 S161] khớp DTO BE `DossierItemId`
fileName: string
fileSize: number
contentType: string | null
purpose: KhkkAttachmentPurposeValue
note: string | null
createdAt: string
}
export interface KhkkLevelOpinionDto {
id: string
approvalWorkflowLevelId: string
stepOrder: number | null
stepName: string | null
levelOrder: number | null
comment: string | null
signedAt: string
signedByUserId: string
signedByFullName: string
}
export interface KhkkDetailDto extends KhkkListItemDto {
workflowName: string | null
currentWorkflowStepIndex: number | null
slaDeadline: string | null
hoSoLink: string | null
ghiChu: string | null
lines: KhkkLineDto[]
dossierItems: KhkkDossierItemDto[]
attachments: KhkkAttachmentDto[]
levelOpinions: KhkkLevelOpinionDto[]
}
// 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.
export interface KhkkPeCandidateDto {
id: string
maPhieu: string | null
tenGoiThau: string
projectName: string | null
workItemName: string | null
winnerSupplierNames: string[]
createdAt: string
}
export interface CreateKhkkInput {
// [F-2 S161] BE Command = `PeId` (D1 — spec là hợp-đồng 2 lane); `purchaseEvaluationId`
// KHÔNG bind ⇒ Guid.Empty ⇒ 400 mọi lần Lưu. Tên field ở đây PHẢI là peId.
peId: string
approvalWorkflowId: string
ghiChu?: string | null
}
export interface UpdateKhkkDraftInput {
approvalWorkflowId?: string | null
ghiChu?: string | null
hoSoLink?: string | null
}
export interface UpsertKhkkDossierItemInput {
// [F-3 S161] BE verify id-path == cmd.ContractSigningPlanId — thiếu trong BODY = 400 vĩnh viễn.
// Optional ở FORM-STATE: mutation inject `contractSigningPlanId: id` lúc submit (KhkkDetailPage).
contractSigningPlanId?: string
id?: string | null
kind: DossierItemKindValue
supplierId?: string | null
name: string
status: DossierItemStatusValue
tvgsName?: string | null
note?: string | null
}
export interface PagedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}