[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
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:
@ -31,6 +31,9 @@ import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
|||||||
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
||||||
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
|
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
|
||||||
import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
|
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 { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
|
||||||
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
||||||
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
|
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) */}
|
{/* 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-calendar" element={<MeetingCalendarPage />} />
|
||||||
<Route path="/meeting-rooms" element={<MeetingRoomsPage />} />
|
<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" element={<ProposalsListPage />} />
|
||||||
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
||||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
|
|||||||
@ -29,14 +29,15 @@ const TYPE_CODE_TO_INT: Record<string, number> = {
|
|||||||
// Ct_<Type>_<Action> sub-menu entries.
|
// Ct_<Type>_<Action> sub-menu entries.
|
||||||
function resolvePath(key: string): string | null {
|
function resolvePath(key: string): string | null {
|
||||||
const staticMap: Record<string, string> = {
|
const staticMap: Record<string, string> = {
|
||||||
// [S159-đợt3] skeleton GĐ2/GĐ4 — leaf trỏ trang "Sắp triển khai" (query giữ
|
// [W2 KHKK S161] GĐ2 hết skeleton — re-point trang thật (gotcha #50: thiếu route =
|
||||||
// active-state riêng từng mục; gotcha #50: thiếu route = sidebar drop SILENT).
|
// sidebar drop SILENT). WfView phía admin trỏ Designer type-10 (fe-admin CỐ Ý không có
|
||||||
Khkk_WfView: '/coming-soon?stage=2&m=wfview',
|
// matrix page — chốt 2026-07-27; admin "Luồng duyệt" = dựng/sửa quy trình).
|
||||||
Khkk_List: '/coming-soon?stage=2&m=list',
|
Khkk_WfView: '/system/approval-workflows-v2/ContractSigningPlan',
|
||||||
Khkk_Create: '/coming-soon?stage=2&m=create',
|
Khkk_List: '/khkk/list',
|
||||||
Khkk_Pending: '/coming-soon?stage=2&m=pending',
|
Khkk_Create: '/khkk/create',
|
||||||
Khkk_Approved: '/coming-soon?stage=2&m=approved',
|
Khkk_Pending: '/khkk/list?filter=ChoDuyet',
|
||||||
Khkk_Deleted: '/coming-soon?stage=2&m=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50)
|
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_ThauPhu: '/coming-soon?stage=4&t=01',
|
||||||
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
|
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
|
||||||
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
|
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
|
||||||
|
|||||||
254
fe-admin/src/pages/khkk/KhkkCreatePage.tsx
Normal file
254
fe-admin/src/pages/khkk/KhkkCreatePage.tsx
Normal 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 có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
680
fe-admin/src/pages/khkk/KhkkDetailPage.tsx
Normal file
680
fe-admin/src/pages/khkk/KhkkDetailPage.tsx
Normal 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ý 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ồ sơ (NAS)</Label>
|
||||||
|
<Input
|
||||||
|
id="hoSoLink"
|
||||||
|
value={hoSoLink}
|
||||||
|
onChange={(e) => setHoSoLink(e.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="\\\\nas\\du-an\\..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ghiChu">Ghi chú</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ghiChu"
|
||||||
|
value={ghiChu}
|
||||||
|
onChange={(e) => setGhiChu(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>
|
||||||
|
Huỷ
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
|
||||||
|
<Save className="mr-2 h-3.5 w-3.5" />
|
||||||
|
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||||
|
<Field label="Link hồ sơ" full>
|
||||||
|
{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 có 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 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.
|
||||||
|
</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 có 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ử lý</Label>
|
||||||
|
<Input
|
||||||
|
id="item-tvgs"
|
||||||
|
value={itemForm.tvgsName ?? ''}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="item-note">Ghi chú</Label>
|
||||||
|
<Textarea
|
||||||
|
id="item-note"
|
||||||
|
value={itemForm.note ?? ''}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setItemForm(null)}>
|
||||||
|
Huỷ
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => upsertItem.mutate(itemForm)}
|
||||||
|
disabled={upsertItem.isPending || !itemForm.name.trim()}
|
||||||
|
>
|
||||||
|
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
fe-admin/src/pages/khkk/KhkkListPage.tsx
Normal file
265
fe-admin/src/pages/khkk/KhkkListPage.tsx
Normal 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">Mã kế hoạch</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đề xuất</th>
|
||||||
|
<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
229
fe-admin/src/types/khkk.ts
Normal 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
|
||||||
|
}
|
||||||
@ -18,6 +18,10 @@ import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pag
|
|||||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
|
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
|
||||||
import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage'
|
import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage'
|
||||||
|
// [W2 KHKK S161] Kế hoạch ký kết HĐ (GĐ2) — 3 page mirror SHA256 với fe-admin.
|
||||||
|
import { KhkkListPage } from '@/pages/khkk/KhkkListPage'
|
||||||
|
import { KhkkCreatePage } from '@/pages/khkk/KhkkCreatePage'
|
||||||
|
import { KhkkDetailPage } from '@/pages/khkk/KhkkDetailPage'
|
||||||
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
||||||
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
||||||
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
||||||
@ -66,6 +70,14 @@ function App() {
|
|||||||
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
|
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
|
||||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||||
|
{/* [W2 KHKK S161] Kế hoạch ký kết HĐ (GĐ2). `/khkk/workflow-matrix` = alias
|
||||||
|
cho leaf "Luồng duyệt" (?type=10) — cùng khuôn alias `/contracts/workflow-matrix`
|
||||||
|
của S159-đợt5, KHÔNG đẻ page mới. */}
|
||||||
|
<Route path="/khkk" element={<Navigate to="/khkk/list" replace />} />
|
||||||
|
<Route path="/khkk/list" element={<KhkkListPage />} />
|
||||||
|
<Route path="/khkk/create" element={<KhkkCreatePage />} />
|
||||||
|
<Route path="/khkk/workflow-matrix" element={<WorkflowMatrixViewPage />} />
|
||||||
|
<Route path="/khkk/:id" element={<KhkkDetailPage />} />
|
||||||
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
||||||
<Route path="/employees" element={<EmployeesListPage />} />
|
<Route path="/employees" element={<EmployeesListPage />} />
|
||||||
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
||||||
|
|||||||
@ -54,12 +54,12 @@ const INT_TO_PE_CODE: Record<number, string> = Object.fromEntries(
|
|||||||
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
|
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
|
||||||
function resolvePath(key: string): string | null {
|
function resolvePath(key: string): string | null {
|
||||||
const staticMap: Record<string, string> = {
|
const staticMap: Record<string, string> = {
|
||||||
// [S159-đợt3] skeleton GĐ2/GĐ4 — leaf trỏ trang "Sắp triển khai" (query giữ
|
// [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50:
|
||||||
// active-state riêng từng mục; gotcha #50: thiếu route = sidebar drop SILENT).
|
// thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE).
|
||||||
Khkk_List: '/coming-soon?stage=2&m=list',
|
Khkk_List: '/khkk/list',
|
||||||
Khkk_Create: '/coming-soon?stage=2&m=create',
|
Khkk_Create: '/khkk/create',
|
||||||
Khkk_Pending: '/coming-soon?stage=2&m=pending',
|
Khkk_Pending: '/khkk/list?filter=ChoDuyet',
|
||||||
Khkk_Approved: '/coming-soon?stage=2&m=approved',
|
Khkk_Approved: '/khkk/list?filter=DaDuyet',
|
||||||
Hdc_ThauPhu: '/coming-soon?stage=4&t=01',
|
Hdc_ThauPhu: '/coming-soon?stage=4&t=01',
|
||||||
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
|
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
|
||||||
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
|
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
|
||||||
@ -73,11 +73,11 @@ function resolvePath(key: string): string | null {
|
|||||||
// [S159 2026-07-29] 2 root placeholder giai đoạn 2/4 (owner: menu hiển thị đúng 4 GĐ
|
// [S159 2026-07-29] 2 root placeholder giai đoạn 2/4 (owner: menu hiển thị đúng 4 GĐ
|
||||||
// như tab strip). Module chưa build → trỏ về Tổng quan pipeline (khung "Sắp triển khai").
|
// như tab strip). Module chưa build → trỏ về Tổng quan pipeline (khung "Sắp triển khai").
|
||||||
// LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT.
|
// LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT.
|
||||||
KeHoachKyKet: '/dashboard',
|
KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu
|
||||||
HopDongCung: '/dashboard',
|
HopDongCung: '/dashboard',
|
||||||
// [S159-đợt5] 2 leaf mới của skeleton GĐ2 (đủ 6 y chang Duyệt NCC)
|
// [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới)
|
||||||
Khkk_WfView: '/coming-soon?stage=2&m=wfview',
|
Khkk_WfView: '/khkk/workflow-matrix?type=10',
|
||||||
Khkk_Deleted: '/coming-soon?stage=2&m=deleted',
|
Khkk_Deleted: '/khkk/list?view=deleted',
|
||||||
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
|
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
|
||||||
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
|
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
|
||||||
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
|
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
|
||||||
|
|||||||
254
fe-user/src/pages/khkk/KhkkCreatePage.tsx
Normal file
254
fe-user/src/pages/khkk/KhkkCreatePage.tsx
Normal 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 có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
680
fe-user/src/pages/khkk/KhkkDetailPage.tsx
Normal file
680
fe-user/src/pages/khkk/KhkkDetailPage.tsx
Normal 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ý 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ồ sơ (NAS)</Label>
|
||||||
|
<Input
|
||||||
|
id="hoSoLink"
|
||||||
|
value={hoSoLink}
|
||||||
|
onChange={(e) => setHoSoLink(e.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="\\\\nas\\du-an\\..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ghiChu">Ghi chú</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ghiChu"
|
||||||
|
value={ghiChu}
|
||||||
|
onChange={(e) => setGhiChu(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>
|
||||||
|
Huỷ
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
|
||||||
|
<Save className="mr-2 h-3.5 w-3.5" />
|
||||||
|
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||||
|
<Field label="Link hồ sơ" full>
|
||||||
|
{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 có 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 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.
|
||||||
|
</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 có 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ử lý</Label>
|
||||||
|
<Input
|
||||||
|
id="item-tvgs"
|
||||||
|
value={itemForm.tvgsName ?? ''}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="item-note">Ghi chú</Label>
|
||||||
|
<Textarea
|
||||||
|
id="item-note"
|
||||||
|
value={itemForm.note ?? ''}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setItemForm(null)}>
|
||||||
|
Huỷ
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => upsertItem.mutate(itemForm)}
|
||||||
|
disabled={upsertItem.isPending || !itemForm.name.trim()}
|
||||||
|
>
|
||||||
|
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
fe-user/src/pages/khkk/KhkkListPage.tsx
Normal file
265
fe-user/src/pages/khkk/KhkkListPage.tsx
Normal 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">Mã kế hoạch</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
||||||
|
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đề xuất</th>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -53,7 +53,8 @@ export function WorkflowMatrixViewPage() {
|
|||||||
const rawType = Number(searchParams.get('type'))
|
const rawType = Number(searchParams.get('type'))
|
||||||
// [S159-đợt5] +3 = Contract (ApplicableType.Contract — HĐ dùng CHUNG 1 bộ workflow V2
|
// [S159-đợt5] +3 = Contract (ApplicableType.Contract — HĐ dùng CHUNG 1 bộ workflow V2
|
||||||
// cho mọi ContractType ⇒ 7 leaf 'Luồng duyệt' của 7 nhóm HĐ đều trỏ về matrix này).
|
// cho mọi ContractType ⇒ 7 leaf 'Luồng duyệt' của 7 nhóm HĐ đều trỏ về matrix này).
|
||||||
const typeInt = rawType === 1 || rawType === 2 || rawType === 3 ? rawType : 1
|
// [W2 KHKK S161] +10 (ContractSigningPlan) — leaf Khkk_WfView đi alias /khkk/workflow-matrix?type=10.
|
||||||
|
const typeInt = rawType === 1 || rawType === 2 || rawType === 3 || rawType === 10 ? rawType : 1
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ['workflow-matrix', typeInt],
|
queryKey: ['workflow-matrix', typeInt],
|
||||||
|
|||||||
229
fe-user/src/types/khkk.ts
Normal file
229
fe-user/src/types/khkk.ts
Normal 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
|
||||||
|
}
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.ContractSigningPlans;
|
||||||
|
using SolutionErp.Domain.ContractSigningPlans;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
// [W2 — S161 2026-07-29] REST "Kế hoạch ký kết HĐ" (GĐ2).
|
||||||
|
//
|
||||||
|
// 🔴 AUTHZ 2 TẦNG (gotcha #82 — menu-flag ≠ API-authz, reviewer-adversarial bắt S118):
|
||||||
|
// tầng 1 = class `[Authorize(Policy = "KeHoachKyKet.Read")]` (mọi endpoint tối thiểu Read)
|
||||||
|
// tầng 2 = per-action override `KeHoachKyKet.{Create|Update|Delete}` — GHI ĐỦ TRÊN MỌI
|
||||||
|
// action, kể cả action chỉ-đọc (viết lại Read cho tường minh): `[Authorize]` trần
|
||||||
|
// hoặc để trống = lỗ hổng, và "ẩn menu" KHÔNG đóng được API.
|
||||||
|
// 4 policy `KeHoachKyKet.*` sinh runtime vì `KeHoachKyKet` ∈ `MenuKeys.All`
|
||||||
|
// (`MenuKeys.cs:42,180`) × `MenuKeys.Actions` trong `Api/Program.cs`. Key KHÔNG nằm
|
||||||
|
// trong `All` ⇒ 500 lúc chạy (policy không tồn tại), không phải 403.
|
||||||
|
//
|
||||||
|
// 🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3.
|
||||||
|
// GlobalExceptionMiddleware map exception → ProblemDetails ⇒ TUYỆT ĐỐI không try-catch ở đây.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/contract-signing-plans")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
// ========================= Đọc =========================
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<ActionResult<PagedResult<ContractSigningPlanListItemDto>>> List(
|
||||||
|
[FromQuery] ContractSigningPlanPhase? phase = null,
|
||||||
|
[FromQuery] Guid? projectId = null,
|
||||||
|
[FromQuery] Guid? purchaseEvaluationId = null,
|
||||||
|
[FromQuery] bool pendingMe = false,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null,
|
||||||
|
[FromQuery] bool sortDesc = true,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(
|
||||||
|
new ListContractSigningPlansQuery(phase, projectId, purchaseEvaluationId, pendingMe)
|
||||||
|
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
|
/// Inbox "chờ tôi duyệt" — wire sẵn cho W3 (W2 trả rỗng vì chưa có đường trình duyệt).
|
||||||
|
[HttpGet("inbox")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<ActionResult<List<ContractSigningPlanListItemDto>>> Inbox(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetMyContractSigningPlanInboxQuery(), ct));
|
||||||
|
|
||||||
|
/// Màn "Đã xoá" — CHỈ XEM.
|
||||||
|
[HttpGet("deleted")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<ActionResult<PagedResult<ContractSigningPlanListItemDto>>> ListDeleted(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null,
|
||||||
|
[FromQuery] bool sortDesc = true,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new ListDeletedContractSigningPlansQuery
|
||||||
|
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
|
/// Picker màn tạo: phiếu Duyệt NCC đã duyệt, có NCC trúng thầu, chưa có kế hoạch sống.
|
||||||
|
[HttpGet("approved-pe-awaiting-plan")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<ActionResult<List<ApprovedPeAwaitingPlanDto>>> ListApprovedPeAwaitingPlan(
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new ListApprovedPeAwaitingPlanQuery(), ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<ActionResult<ContractSigningPlanDetailDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetContractSigningPlanQuery(id), ct));
|
||||||
|
|
||||||
|
// ========================= Ghi header =========================
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Create")]
|
||||||
|
public async Task<ActionResult<CreateContractSigningPlanResult>> Create(
|
||||||
|
[FromBody] CreateContractSigningPlanCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(cmd, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<IActionResult> UpdateDraft(
|
||||||
|
Guid id, [FromBody] UpdateContractSigningPlanDraftBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateContractSigningPlanDraftCommand(
|
||||||
|
id, body.GhiChu, body.HoSoLink, body.ApprovalWorkflowId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Delete")]
|
||||||
|
public async Task<IActionResult> Delete(
|
||||||
|
Guid id, [FromQuery] string? reason = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteContractSigningPlanCommand(id, reason), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= Căn cứ b.8-9 (dossier items) =================
|
||||||
|
// Ghi vào phiếu ⇒ policy Update (KHÔNG Create): "tạo" ở module này = tạo PHIẾU.
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/dossier-items")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<ActionResult<object>> CreateDossierItem(
|
||||||
|
Guid id, [FromBody] UpsertContractSigningPlanDossierItemCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (id != cmd.ContractSigningPlanId)
|
||||||
|
return BadRequest(new { detail = "ID kế hoạch trên đường dẫn không khớp dữ liệu gửi lên." });
|
||||||
|
var newId = await mediator.Send(cmd with { Id = null }, ct);
|
||||||
|
return Ok(new { id = newId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/dossier-items/{itemId:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<ActionResult<object>> UpdateDossierItem(
|
||||||
|
Guid id, Guid itemId,
|
||||||
|
[FromBody] UpsertContractSigningPlanDossierItemCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (id != cmd.ContractSigningPlanId)
|
||||||
|
return BadRequest(new { detail = "ID kế hoạch trên đường dẫn không khớp dữ liệu gửi lên." });
|
||||||
|
var savedId = await mediator.Send(cmd with { Id = itemId }, ct);
|
||||||
|
return Ok(new { id = savedId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/dossier-items/{itemId:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<IActionResult> DeleteDossierItem(Guid id, Guid itemId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteContractSigningPlanDossierItemCommand(id, itemId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Đính kèm (mở mọi phase) =====================
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/attachments")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
[RequestSizeLimit(25_000_000)]
|
||||||
|
public async Task<ActionResult<ContractSigningPlanAttachmentDto>> UploadAttachment(
|
||||||
|
Guid id,
|
||||||
|
IFormFile file,
|
||||||
|
[FromForm] Guid? dossierItemId = null,
|
||||||
|
[FromForm] ContractSigningPlanAttachmentPurpose purpose = ContractSigningPlanAttachmentPurpose.DossierScan,
|
||||||
|
[FromForm] string? note = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
return BadRequest(new { detail = "Chưa chọn file." });
|
||||||
|
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var dto = await mediator.Send(new UploadContractSigningPlanAttachmentCommand(
|
||||||
|
id, dossierItemId, file.FileName, file.ContentType, file.Length, stream, purpose, note), ct);
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var f = await mediator.Send(new DownloadContractSigningPlanAttachmentQuery(id, attId), ct);
|
||||||
|
return File(f.Content, f.ContentType, f.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xem inline (PDF iframe / ảnh) — cùng handler download, khác Content-Disposition.
|
||||||
|
[HttpGet("{id:guid}/attachments/{attId:guid}/view")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
|
public async Task<IActionResult> ViewAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var f = await mediator.Send(new DownloadContractSigningPlanAttachmentQuery(id, attId), ct);
|
||||||
|
// [W2 S161 — reviewer F-11] KHÔNG nội suy FileName thô vào header: tên có dấu/ký tự `"`
|
||||||
|
// làm header non-ASCII/vỡ cú pháp. ContentDispositionHeaderValue tự mã hoá RFC 6266
|
||||||
|
// (filename*) — cùng cơ chế MVC dùng cho đường /download.
|
||||||
|
var cd = new Microsoft.Net.Http.Headers.ContentDispositionHeaderValue("inline");
|
||||||
|
cd.SetHttpFileName(f.FileName);
|
||||||
|
Response.Headers.ContentDisposition = cd.ToString();
|
||||||
|
return File(f.Content, f.ContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteContractSigningPlanAttachmentCommand(id, attId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================= Body records =========================
|
||||||
|
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
|
||||||
|
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
|
||||||
|
public record UpdateContractSigningPlanDraftBody(
|
||||||
|
string? GhiChu = null,
|
||||||
|
string? HoSoLink = null,
|
||||||
|
Guid? ApprovalWorkflowId = null);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,9 @@ namespace SolutionErp.Application.ContractSigningPlans.Services;
|
|||||||
// - Sequence per-năm qua bảng WorkflowAppCodeSequences DÙNG CHUNG (per-prefix row,
|
// - Sequence per-năm qua bảng WorkflowAppCodeSequences DÙNG CHUNG (per-prefix row,
|
||||||
// IApplicationDbContext) — KHÔNG bảng sequence riêng (spec W1 §②-9, #8-CẮT spec cũ).
|
// IApplicationDbContext) — KHÔNG bảng sequence riêng (spec W1 §②-9, #8-CẮT spec cũ).
|
||||||
// - Seq reset đầu năm tự nhiên (key Prefix "KHKK/{YYYY}" mới).
|
// - Seq reset đầu năm tự nhiên (key Prefix "KHKK/{YYYY}" mới).
|
||||||
// Caller: W2 Submit (gen lúc trình duyệt, KHÔNG lúc tạo nháp — mirror quyết spec cũ).
|
// Caller: W2 `CreateContractSigningPlanCommandHandler` — gen NGAY LÚC TẠO phiếu.
|
||||||
|
// 🔴 Đính chính bản W1 ("gen lúc Submit"): spec W2 §③-B đòi POST trả `maKeHoach` khớp
|
||||||
|
// `^KHKK/\d{4}/\d{3}$` ⇒ mã phải có từ lúc tạo, không defer tới trình duyệt.
|
||||||
public interface IContractSigningPlanCodeGenerator
|
public interface IContractSigningPlanCodeGenerator
|
||||||
{
|
{
|
||||||
// Gen mã atomic — transaction SERIALIZABLE chống race (mirror PE/Contract codegen).
|
// Gen mã atomic — transaction SERIALIZABLE chống race (mirror PE/Contract codegen).
|
||||||
|
|||||||
@ -14,7 +14,12 @@ namespace SolutionErp.Domain.ContractSigningPlans;
|
|||||||
// nội bộ module (Lines/DossierItems/... Cascade) + ApprovalWorkflowId → bảng V2 dùng chung.
|
// nội bộ module (Lines/DossierItems/... Cascade) + ApprovalWorkflowId → bảng V2 dùng chung.
|
||||||
public class ContractSigningPlan : AuditableEntity
|
public class ContractSigningPlan : AuditableEntity
|
||||||
{
|
{
|
||||||
public string? MaKeHoach { get; set; } // "KHKK/2026/001" — gen atomic lúc Submit (W2)
|
// [W2 S161] "KHKK/2026/001" — gen atomic NGAY LÚC TẠO phiếu (POST trả về mã liền).
|
||||||
|
// 🔴 Đính chính comment W1 ("gen lúc Submit"): spec W2 §③-B chốt acceptance
|
||||||
|
// `POST /api/contract-signing-plans` → mã khớp `^KHKK/\d{4}/\d{3}$` ⇒ không thể defer
|
||||||
|
// tới Submit. Vẫn để nullable vì cột đã sinh nullable ở Mig 69 (không đổi schema) —
|
||||||
|
// phiếu tạo bằng đường CQRS luôn có mã.
|
||||||
|
public string? MaKeHoach { get; set; }
|
||||||
|
|
||||||
public Guid PurchaseEvaluationId { get; set; } // loose-Guid + IX — phiếu PE nguồn (DaDuyet)
|
public Guid PurchaseEvaluationId { get; set; } // loose-Guid + IX — phiếu PE nguồn (DaDuyet)
|
||||||
public Guid ProjectId { get; set; } // denorm để lọc list (loose-Guid, mirror PE)
|
public Guid ProjectId { get; set; } // denorm để lọc list (loose-Guid, mirror PE)
|
||||||
|
|||||||
@ -21,8 +21,10 @@ public class ContractSigningPlanConfiguration : IEntityTypeConfiguration<Contrac
|
|||||||
b.Property(x => x.HoSoLink).HasMaxLength(1000);
|
b.Property(x => x.HoSoLink).HasMaxLength(1000);
|
||||||
b.Property(x => x.GhiChu).HasMaxLength(2000);
|
b.Property(x => x.GhiChu).HasMaxLength(2000);
|
||||||
|
|
||||||
// Mã phiếu UNIQUE khi đã gen (gen lúc Submit ⇒ nháp còn null) — mirror
|
// Mã phiếu UNIQUE khi đã gen — mirror PurchaseEvaluationConfiguration.cs:56 /
|
||||||
// PurchaseEvaluationConfiguration.cs:56 / ProposalConfiguration.cs:23.
|
// ProposalConfiguration.cs:23. 🔴 Đính chính comment W1 ("gen lúc Submit ⇒ nháp còn
|
||||||
|
// null"): W2 gen NGAY LÚC TẠO ⇒ thực tế cột luôn NOT NULL. Filter GIỮ NGUYÊN (rộng
|
||||||
|
// hơn mức cần, không sai) — đổi filter = migration mới, không đáng.
|
||||||
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
|
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex(x => x.PurchaseEvaluationId);
|
b.HasIndex(x => x.PurchaseEvaluationId);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing; // HttpMethodAttribute — đọc verb thật của action (W2 KHKK)
|
||||||
using SolutionErp.Api.Controllers;
|
using SolutionErp.Api.Controllers;
|
||||||
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
|
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
|
||||||
|
|
||||||
@ -321,4 +322,121 @@ public class AuthorizePolicyRegressionTests
|
|||||||
"GET my-dashboard KHÔNG được gate Policy — mỗi user chỉ thấy số của chính mình. " +
|
"GET my-dashboard KHÔNG được gate Policy — mỗi user chỉ thấy số của chính mình. " +
|
||||||
"Thêm policy sẽ khóa dashboard cá nhân của role không có Reports.Read (S118 giữ mở có chủ đích).");
|
"Thêm policy sẽ khóa dashboard cá nhân của role không có Reports.Read (S118 giữ mở có chủ đích).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// [W2 KHKK — S161 2026-07-29] ContractSigningPlansController (GĐ2 "Kế hoạch ký kết HĐ").
|
||||||
|
//
|
||||||
|
// Shape KHÁC 3 controller ở trên — đừng ép về cùng một kiểu:
|
||||||
|
// class-level CÓ Policy `KeHoachKyKet.Read` (mọi endpoint tối thiểu Read) + per-action
|
||||||
|
// override GHI ĐỦ 15/15 action, KỂ CẢ action chỉ-đọc (viết lại Read cho tường minh).
|
||||||
|
// Lý do ghi thành lời ở `ContractSigningPlansController.cs:12-19`.
|
||||||
|
//
|
||||||
|
// Test này quét TOÀN BỘ action (không liệt tên tay) vì rủi ro thật của module mới là
|
||||||
|
// action thứ 16 thêm sau này QUÊN attribute — liệt tay thì action mới không bao giờ bị soi.
|
||||||
|
// 3 lớp răng, mỗi lớp bắt một hỏng khác nhau:
|
||||||
|
// (a) THIẾU attribute → endpoint tụt xuống class-level Read ⇒ ai đọc được cũng GHI được;
|
||||||
|
// (b) SAI tên policy → `Program.cs` chỉ sinh policy cho key ∈ `MenuKeys.All`; tên lạ
|
||||||
|
// = 500 lúc chạy (KHÔNG phải 403) — attribute vẫn "trông đúng";
|
||||||
|
// (c) verb ghi mà policy Read → 403 không bao giờ xảy ra: user chỉ-xem GHI được vào phiếu.
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Bộ action của controller = method public khai TRỰC TIẾP trên class + có `[HttpXxx]`.
|
||||||
|
/// (Lọc theo HttpMethodAttribute để bỏ property/ctor/record lồng bên trong.)
|
||||||
|
private static (string Name, string Verb, AuthorizeAttribute? Authz)[] GetActionsWithVerb(Type controllerType)
|
||||||
|
=> controllerType
|
||||||
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Name,
|
||||||
|
Http = m.GetCustomAttributes<HttpMethodAttribute>(inherit: false).FirstOrDefault(),
|
||||||
|
Authz = m.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.Where(x => x.Http is not null)
|
||||||
|
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
|
||||||
|
{
|
||||||
|
var type = typeof(ContractSigningPlansController);
|
||||||
|
|
||||||
|
// ---- (0) Class-level: 2 tầng — tầng 1 CÓ Policy Read (cố ý, khác ApprovalWorkflowsV2).
|
||||||
|
var classAttr = GetClassLevelAuthorize(type);
|
||||||
|
classAttr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
|
||||||
|
classAttr!.Policy.Should().Be("KeHoachKyKet.Read",
|
||||||
|
"tầng 1 của authz 2 tầng: MỌI endpoint KHKK tối thiểu phải có quyền đọc menu này. " +
|
||||||
|
"Đây KHÔNG phải bẫy gotcha #44 (silent 403) vì module có menu key riêng — user được " +
|
||||||
|
"cấp KeHoachKyKet mới thấy màn hình này, không phải mọi Drafter như ApprovalWorkflowsV2.");
|
||||||
|
|
||||||
|
var actions = GetActionsWithVerb(type);
|
||||||
|
|
||||||
|
// ---- Neo chống VACUOUS: foreach trên mảng rỗng luôn xanh. 15 action đo từ đĩa
|
||||||
|
// 2026-07-29 (5 GET đọc + POST/PUT/DELETE phiếu + 3 dossier-item + 4 attachment).
|
||||||
|
actions.Should().HaveCountGreaterThanOrEqualTo(15,
|
||||||
|
"quét được {0} action — dưới 15 nghĩa là phép quét hỏng (đổi BindingFlags/attribute) " +
|
||||||
|
"chứ không phải controller teo lại; lúc đó mọi assert dưới đây thành vô nghĩa",
|
||||||
|
actions.Length);
|
||||||
|
|
||||||
|
// Verb GHI = mọi thứ không phải GET/HEAD. Không hardcode danh sách endpoint.
|
||||||
|
var writeVerbs = new[] { "POST", "PUT", "PATCH", "DELETE" };
|
||||||
|
|
||||||
|
foreach (var (name, verb, authz) in actions)
|
||||||
|
{
|
||||||
|
// ---- (a) MỌI action phải có attribute riêng (kể cả GET — luật của module này).
|
||||||
|
authz.Should().NotBeNull(
|
||||||
|
"action {0} ({1}) thiếu [Authorize(Policy = ...)] — nó sẽ tụt xuống class-level " +
|
||||||
|
"`KeHoachKyKet.Read`, nghĩa là ai ĐỌC được cũng GHI được (gotcha #82)", name, verb);
|
||||||
|
authz!.Policy.Should().NotBeNullOrEmpty(
|
||||||
|
"action {0} có [Authorize] TRẦN — `[Authorize]` không Policy = mọi user đăng nhập " +
|
||||||
|
"lọt qua tầng 2 (bài F-1b S155)", name);
|
||||||
|
|
||||||
|
var parts = authz.Policy!.Split('.');
|
||||||
|
parts.Should().HaveCount(2,
|
||||||
|
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
|
||||||
|
|
||||||
|
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
|
||||||
|
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
|
||||||
|
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
|
||||||
|
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
|
||||||
|
"khác = cấp quyền chéo", name);
|
||||||
|
MenuKeys.All.Should().Contain(parts[0],
|
||||||
|
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
|
||||||
|
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
|
||||||
|
MenuKeys.Actions.Should().Contain(parts[1],
|
||||||
|
"policy \"{0}\" của {1} dùng action không có trong MenuKeys.Actions", authz.Policy, name);
|
||||||
|
|
||||||
|
// ---- (c) Verb GHI KHÔNG được gate bằng Read (403 sẽ không bao giờ xảy ra).
|
||||||
|
if (writeVerbs.Contains(verb))
|
||||||
|
{
|
||||||
|
parts[1].Should().NotBe("Read",
|
||||||
|
"{0} là {1} (ghi) mà chỉ gate `Read` ⇒ user chỉ-xem VẪN ghi được vào phiếu", name, verb);
|
||||||
|
parts[1].Should().BeOneOf(["Create", "Update", "Delete"],
|
||||||
|
"verb ghi {0} phải gate Create/Update/Delete", name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
parts[1].Should().Be("Read",
|
||||||
|
"{0} là {1} (đọc) — gate mạnh hơn Read sẽ khóa nhầm người chỉ có quyền xem " +
|
||||||
|
"(403 silent, gotcha #44)", name, verb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- (d) Chốt 3 endpoint mấu chốt bằng TÊN — nếu ai đổi POST phiếu sang `Update`
|
||||||
|
// thì luật (c) ở trên vẫn xanh (Update cũng là verb ghi hợp lệ).
|
||||||
|
var byName = actions.ToDictionary(a => a.Name, a => a.Authz?.Policy);
|
||||||
|
byName[nameof(ContractSigningPlansController.Create)].Should().Be("KeHoachKyKet.Create",
|
||||||
|
"POST tạo phiếu = quyền Create");
|
||||||
|
byName[nameof(ContractSigningPlansController.UpdateDraft)].Should().Be("KeHoachKyKet.Update",
|
||||||
|
"PUT sửa nháp = quyền Update");
|
||||||
|
byName[nameof(ContractSigningPlansController.Delete)].Should().Be("KeHoachKyKet.Delete",
|
||||||
|
"DELETE phiếu = quyền Delete RIÊNG (không gộp Update) — xoá là hành vi khác sửa");
|
||||||
|
|
||||||
|
// Ghi vào phiếu (căn cứ b.8-9 / đính kèm) = Update chứ KHÔNG Create: "tạo" ở module
|
||||||
|
// này nghĩa là tạo PHIẾU. Chốt lại để không ai "sửa cho nhất quán" nhầm hướng.
|
||||||
|
byName[nameof(ContractSigningPlansController.CreateDossierItem)].Should().Be("KeHoachKyKet.Update",
|
||||||
|
"thêm căn cứ = ghi vào phiếu đã có ⇒ Update; gate Create sẽ chặn oan người được giao " +
|
||||||
|
"quyền sửa phiếu nhưng không được lập phiếu mới");
|
||||||
|
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
|
||||||
|
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,563 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
|
||||||
|
using SolutionErp.Application.ContractSigningPlans;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.ContractSigningPlans;
|
||||||
|
using SolutionErp.Domain.Contracts; // ChangelogAction (enum dùng chung 3 module)
|
||||||
|
using SolutionErp.Domain.Identity; // AppRoles
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// [W2 KHKK — S161 2026-07-29] CRUD phiếu "Kế hoạch ký kết HĐ" (GĐ2) — test-AFTER
|
||||||
|
// (feature mới, `docs/rules.md §7`), chốt acceptance spec W2 §③-B.
|
||||||
|
//
|
||||||
|
// Code under test: `Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`
|
||||||
|
// Create :292-400 (4 rào theo thứ tự + codegen + auto-sinh Lines)
|
||||||
|
// Delete :1098-1142 (owner ∥ Admin + allow-list phase + thứ tự Remove/Add)
|
||||||
|
//
|
||||||
|
// ── 2 quyết định hạ tầng, cả hai đều là ĐIỀU KIỆN để phép đo có nghĩa ──────────
|
||||||
|
//
|
||||||
|
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng SqliteDbFixture/IdentityFixture).
|
||||||
|
// `TestApplicationDbContext` mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá
|
||||||
|
// **CỨNG** (bài S155). Đo "xoá xong lập lại được" trên xoá-cứng là đo NHẦM cơ chế:
|
||||||
|
// rào (iii) của Create dựa vào GLOBAL QUERY FILTER che row `IsDeleted=1`
|
||||||
|
// (`:323-325`), chứ không phải row biến mất khỏi bảng. Không có interceptor thì
|
||||||
|
// test vẫn XANH kể cả khi filter bị gỡ — đúng kiểu vacuous.
|
||||||
|
// Không cần Identity stack: 2 handler dưới test chỉ nhận `db + currentUser (+ codeGen)`.
|
||||||
|
//
|
||||||
|
// (2) MỖI lệnh chạy trên MỘT DbContext MỚI (`NewDb()`), dùng chung 1 connection —
|
||||||
|
// mô phỏng scoped-per-request của prod. Nếu tái dùng 1 context cho cả seed lẫn
|
||||||
|
// lệnh, các con (Lines/Changelogs) đang được TRACK sẽ bị EF cascade client-side
|
||||||
|
// ngay tại `Remove()` — một đường đi KHÔNG hề xảy ra trong prod (ở đó context mới
|
||||||
|
// chỉ nạp mỗi header). Sai lệch này chính là thứ đẻ ra bug T26 S155, nên nó phải
|
||||||
|
// được mô phỏng cho ĐÚNG chứ không được làm cho tiện.
|
||||||
|
public class ContractSigningPlanCrudTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Fixture: SQLite in-memory + AuditingInterceptor (xoá mềm THẬT).
|
||||||
|
// ===================================================================
|
||||||
|
private sealed class KhkkFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _conn;
|
||||||
|
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||||
|
|
||||||
|
/// Người đang thao tác — đổi được giữa chừng để đóng vai user khác.
|
||||||
|
public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "BCH lập kế hoạch", "bch@test.local");
|
||||||
|
|
||||||
|
public KhkkFixture()
|
||||||
|
{
|
||||||
|
_conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_conn.Open();
|
||||||
|
using var boot = NewDb();
|
||||||
|
boot.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context MỚI cho mỗi "request" (xem ghi chú (2) đầu file).
|
||||||
|
/// 🔴 `actor` phải là ĐÚNG cái `ICurrentUser` mà handler nhận: trong prod cả
|
||||||
|
/// interceptor lẫn handler đều resolve CÙNG một `ICurrentUser` scoped. Nếu để
|
||||||
|
/// interceptor dính cứng một người còn handler chạy bằng người khác thì
|
||||||
|
/// `DeletedBy`/`UpdatedBy` đo ra người sai — và tệ hơn, nó sai theo hướng
|
||||||
|
/// TRÔNG NHƯ ĐÚNG (vẫn có giá trị, vẫn là một Guid user thật).
|
||||||
|
public TestApplicationDbContext NewDb(ICurrentUser? actor = null)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(_conn)
|
||||||
|
.EnableSensitiveDataLogging()
|
||||||
|
.AddInterceptors(new AuditingInterceptor(actor ?? Actor, Clock))
|
||||||
|
.Options;
|
||||||
|
return new TestApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _conn.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Seed: 1 PE + N nhà cung cấp + M hạng mục báo giá + 1 quy trình duyệt.
|
||||||
|
// ===================================================================
|
||||||
|
private sealed record SeededPe(
|
||||||
|
Guid PeId,
|
||||||
|
Guid ProjectId,
|
||||||
|
Guid DepartmentId,
|
||||||
|
Guid PeDrafterId,
|
||||||
|
Guid WorkflowId,
|
||||||
|
List<Guid> MasterSupplierIds, // Id ở bảng Suppliers (master)
|
||||||
|
List<Guid> PeSupplierRowIds); // Id ở bảng PurchaseEvaluationSuppliers (ROW của phiếu)
|
||||||
|
|
||||||
|
/// <param name="quotes">quotes[hạng mục][NCC] = ThanhTien.</param>
|
||||||
|
/// <param name="selected">selected[hạng mục][NCC] = IsSelected; null ⇒ mặc định "quote của winner được chọn".</param>
|
||||||
|
private static async Task<SeededPe> SeedPeAsync(
|
||||||
|
KhkkFixture f,
|
||||||
|
int supplierCount,
|
||||||
|
int[] winnerIndexes,
|
||||||
|
decimal[][] quotes,
|
||||||
|
bool[][]? selected = null,
|
||||||
|
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
ApprovalWorkflowApplicableType wfType = ApprovalWorkflowApplicableType.ContractSigningPlan)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
|
||||||
|
// Quy trình duyệt V2 — BẮT BUỘC là row thật: `ContractSigningPlan.ApprovalWorkflowId`
|
||||||
|
// là FK Restrict (`ContractSigningPlanConfiguration.cs:39-42`), Guid rác = SQLite lỗi 19.
|
||||||
|
// KHÔNG cần Step/Level (Level.ApproverUserId mới là FK sang Users) — W2 chưa duyệt.
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-KHKK-V2",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = wfType,
|
||||||
|
Name = "Quy trình duyệt kế hoạch ký kết",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
|
||||||
|
var peId = Guid.NewGuid();
|
||||||
|
var masterIds = new List<Guid>();
|
||||||
|
var rowIds = new List<Guid>();
|
||||||
|
var peSuppliers = new List<PurchaseEvaluationSupplier>();
|
||||||
|
|
||||||
|
for (var i = 0; i < supplierCount; i++)
|
||||||
|
{
|
||||||
|
var master = new Supplier
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = $"NCC{i + 1}",
|
||||||
|
Name = $"Nhà cung cấp {i + 1}",
|
||||||
|
Type = SupplierType.NhaThauPhu,
|
||||||
|
};
|
||||||
|
db.Suppliers.Add(master);
|
||||||
|
|
||||||
|
var row = new PurchaseEvaluationSupplier
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), // ≠ master.Id — chính là cái bẫy w.Id vs w.SupplierId
|
||||||
|
PurchaseEvaluationId = peId,
|
||||||
|
SupplierId = master.Id,
|
||||||
|
Order = i,
|
||||||
|
IsWinner = winnerIndexes.Contains(i),
|
||||||
|
};
|
||||||
|
masterIds.Add(master.Id);
|
||||||
|
rowIds.Add(row.Id);
|
||||||
|
peSuppliers.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Người soạn PE (QS) CỐ Ý khác người lập kế hoạch (BCH) — dùng để chứng D2.
|
||||||
|
var peDrafterId = Guid.NewGuid();
|
||||||
|
var projectId = Guid.NewGuid();
|
||||||
|
var departmentId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var pe = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = peId,
|
||||||
|
MaPhieu = "PE-KHKK-001",
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = phase,
|
||||||
|
TenGoiThau = "Cung cấp và lắp dựng cốp pha",
|
||||||
|
ProjectId = projectId,
|
||||||
|
DepartmentId = departmentId,
|
||||||
|
DrafterUserId = peDrafterId,
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
};
|
||||||
|
pe.Suppliers.AddRange(peSuppliers);
|
||||||
|
|
||||||
|
for (var d = 0; d < quotes.Length; d++)
|
||||||
|
{
|
||||||
|
var detail = new PurchaseEvaluationDetail
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PurchaseEvaluationId = peId,
|
||||||
|
GroupCode = "A.I",
|
||||||
|
GroupName = "Cốp pha",
|
||||||
|
NoiDung = $"Hạng mục {d + 1}",
|
||||||
|
Order = d,
|
||||||
|
ThanhTienNganSach = 1_000_000_000m, // ngân sách CỐ Ý lệch hẳn báo giá
|
||||||
|
};
|
||||||
|
for (var s = 0; s < supplierCount; s++)
|
||||||
|
{
|
||||||
|
detail.Quotes.Add(new PurchaseEvaluationQuote
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PurchaseEvaluationDetailId = detail.Id,
|
||||||
|
PurchaseEvaluationSupplierId = peSuppliers[s].Id,
|
||||||
|
ThanhTien = quotes[d][s],
|
||||||
|
IsSelected = selected is not null ? selected[d][s] : winnerIndexes.Contains(s),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pe.Details.Add(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
return new SeededPe(peId, projectId, departmentId, peDrafterId, wf.Id, masterIds, rowIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chạy lệnh qua handler THẬT, mỗi lệnh 1 context mới ----
|
||||||
|
|
||||||
|
private static async Task<CreateContractSigningPlanResult> CreateAsync(
|
||||||
|
KhkkFixture f, Guid peId, Guid workflowId, string? ghiChu = null)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
var handler = new CreateContractSigningPlanCommandHandler(
|
||||||
|
db, f.Actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
|
||||||
|
return await handler.Handle(
|
||||||
|
new CreateContractSigningPlanCommand(peId, workflowId, ghiChu), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteAsync(KhkkFixture f, Guid planId, string? reason, TestCurrentUser actor)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(actor); // interceptor + handler CÙNG một actor (như prod)
|
||||||
|
var handler = new DeleteContractSigningPlanCommandHandler(db, actor);
|
||||||
|
await handler.Handle(new DeleteContractSigningPlanCommand(planId, reason), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (1) Tên PIN theo spec §③-B — PE chưa duyệt xong thì KHÔNG lập được kế hoạch.
|
||||||
|
//
|
||||||
|
// Rào này là rào ĐẦU TIÊN (`:312-313`) nên nó cũng gác luôn 3 rào sau: sai ở đây
|
||||||
|
// là phiếu GĐ2 chốt giá trên một phiếu Duyệt NCC còn đang tranh luận.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateContractSigningPlan_FromNonApprovedPe_Throws()
|
||||||
|
{
|
||||||
|
using var f = new KhkkFixture();
|
||||||
|
|
||||||
|
// PE ChoDuyet — MỌI thứ khác đã hợp lệ (có winner, có báo giá được chọn, workflow
|
||||||
|
// đúng loại) ⇒ nếu ném thì chỉ có thể vì phase.
|
||||||
|
var seeded = await SeedPeAsync(f,
|
||||||
|
supplierCount: 2,
|
||||||
|
winnerIndexes: [0],
|
||||||
|
quotes: [[100m, 7m]],
|
||||||
|
phase: PurchaseEvaluationPhase.ChoDuyet);
|
||||||
|
|
||||||
|
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"phiếu Duyệt NCC chưa ở trạng thái Đã duyệt thì chưa có giá chốt để lập kế hoạch"))
|
||||||
|
.WithMessage("*Đã duyệt*");
|
||||||
|
|
||||||
|
await using (var db = f.NewDb())
|
||||||
|
{
|
||||||
|
db.ContractSigningPlans.IgnoreQueryFilters().Count()
|
||||||
|
.Should().Be(0, "ném xong KHÔNG được để lại phiếu nửa vời");
|
||||||
|
|
||||||
|
// Rào phase đứng TRƯỚC codegen (`:312` vs `:339`) ⇒ không đốt số thứ tự.
|
||||||
|
// Nếu ai đổi thứ tự, mã KHKK sẽ có lỗ mà chẳng phiếu nào mang.
|
||||||
|
db.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026")
|
||||||
|
.Should().BeFalse("lỗi ở rào đầu thì chưa được cấp phát mã nào");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VẾ ĐỐI CHỨNG (bắt buộc): cùng bộ seed, ĐỔI ĐÚNG 1 BIẾN = phase → phải CHẠY.
|
||||||
|
// Thiếu vế này, test trên vẫn xanh trong cái thế giới mà seed hỏng sẵn (FK/required
|
||||||
|
// thiếu) — lúc đó nó đo lỗi của chính mình chứ không đo rào.
|
||||||
|
using var ok = new KhkkFixture();
|
||||||
|
var okSeed = await SeedPeAsync(ok,
|
||||||
|
supplierCount: 2,
|
||||||
|
winnerIndexes: [0],
|
||||||
|
quotes: [[100m, 7m]],
|
||||||
|
phase: PurchaseEvaluationPhase.DaDuyet);
|
||||||
|
|
||||||
|
var result = await CreateAsync(ok, okSeed.PeId, okSeed.WorkflowId);
|
||||||
|
result.LineCount.Should().Be(1, "cùng seed đó với PE Đã duyệt thì lập được kế hoạch bình thường");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (2) Happy path — acceptance §③-B: N Lines = số winner, `PeReferenceAmount` =
|
||||||
|
// SUM báo giá ĐƯỢC CHỌN của ĐÚNG NCC đó, mã khớp `^KHKK/\d{4}/\d{3}$`.
|
||||||
|
//
|
||||||
|
// Seed cài sẵn 3 cái bẫy, mỗi cái bắt một lỗi khác nhau:
|
||||||
|
// • NCC thua (idx 2) có báo giá `IsSelected = true` và số TO → lọt = quên lọc winner;
|
||||||
|
// • winner 1 có 1 hạng mục `IsSelected = false` → cộng = quên lọc IsSelected;
|
||||||
|
// • Id ROW ≠ Id master supplier → dùng nhầm = sai NCC hoàn toàn
|
||||||
|
// (bẫy `w.Id` vs `w.SupplierId` mà lane BE tự khai ở sub-md §1).
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateContractSigningPlan_HappyPath_LinesMatchWinners_AndPeReferenceAmountIsSelectedQuoteSum()
|
||||||
|
{
|
||||||
|
using var f = new KhkkFixture();
|
||||||
|
|
||||||
|
// 3 NCC: 0 + 1 trúng, 2 thua. 2 hạng mục.
|
||||||
|
// winner0: 100 + 40 = 140 (cả 2 được chọn)
|
||||||
|
// winner1: 200 + [60] = 200 (hạng mục 2 KHÔNG được chọn)
|
||||||
|
// loser2 : 999 + 888 (được chọn hết — mồi nhử)
|
||||||
|
var seeded = await SeedPeAsync(f,
|
||||||
|
supplierCount: 3,
|
||||||
|
winnerIndexes: [0, 1],
|
||||||
|
quotes:
|
||||||
|
[
|
||||||
|
[100m, 200m, 999m],
|
||||||
|
[40m, 60m, 888m],
|
||||||
|
],
|
||||||
|
selected:
|
||||||
|
[
|
||||||
|
[true, true, true],
|
||||||
|
[true, false, true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, "Kế hoạch ký kết đợt 1");
|
||||||
|
|
||||||
|
// ---- Kết quả trả về (đúng thứ FE đọc để điều hướng sau khi POST).
|
||||||
|
result.LineCount.Should().Be(2, "2 NCC trúng thầu ⇒ 2 dòng giá");
|
||||||
|
result.MaKeHoach.Should().MatchRegex(@"^KHKK/\d{4}/\d{3}$", "acceptance §③-B");
|
||||||
|
result.MaKeHoach.Should().Be("KHKK/2026/001",
|
||||||
|
"regex một mình vẫn xanh khi năm bị hardcode hoặc seq đứng yên — chốt luôn giá trị tuyệt đối");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
var plan = await db.ContractSigningPlans.AsNoTracking()
|
||||||
|
.Include(p => p.Lines)
|
||||||
|
.SingleAsync(p => p.Id == result.Id);
|
||||||
|
|
||||||
|
plan.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao, "lập xong là NHÁP, chưa trình");
|
||||||
|
plan.MaKeHoach.Should().Be(result.MaKeHoach, "mã trong DB phải khớp mã trả về");
|
||||||
|
plan.ApprovalWorkflowId.Should().Be(seeded.WorkflowId, "quy trình pin ngay lúc tạo");
|
||||||
|
plan.GhiChu.Should().Be("Kế hoạch ký kết đợt 1");
|
||||||
|
|
||||||
|
// ---- Denorm từ PE.
|
||||||
|
plan.PurchaseEvaluationId.Should().Be(seeded.PeId);
|
||||||
|
plan.ProjectId.Should().Be(seeded.ProjectId, "dự án lấy từ phiếu nguồn");
|
||||||
|
plan.DepartmentId.Should().Be(seeded.DepartmentId, "phòng lấy từ phiếu nguồn");
|
||||||
|
|
||||||
|
// ---- D2: người soạn kế hoạch = NGƯỜI ĐANG TẠO, không phải người soạn PE.
|
||||||
|
// 2 vế: bằng người tạo ∧ KHÁC người soạn PE. Chỉ có vế đầu thì trong thế giới
|
||||||
|
// "copy pe.DrafterUserId" test vẫn có thể xanh nếu hai người trùng nhau.
|
||||||
|
plan.DrafterUserId.Should().Be(f.Actor.UserId!.Value, "BCH lập kế hoạch là chủ phiếu GĐ2");
|
||||||
|
plan.DrafterUserId.Should().NotBe(seeded.PeDrafterId,
|
||||||
|
"người soạn phiếu Duyệt NCC (QS) là vai KHÁC — copy sang là trao nhầm quyền sửa/xoá");
|
||||||
|
|
||||||
|
// ---- Lines: đúng 2 NCC trúng, trỏ Id MASTER (không phải Id row của phiếu PE).
|
||||||
|
var lines = plan.Lines.OrderBy(l => l.PeReferenceAmount).ToList();
|
||||||
|
lines.Select(l => l.SupplierId).Should().BeEquivalentTo(
|
||||||
|
new[] { seeded.MasterSupplierIds[0], seeded.MasterSupplierIds[1] },
|
||||||
|
"dòng giá phải trỏ NCC ở danh mục master");
|
||||||
|
lines.Select(l => l.SupplierId).Should().NotContain(seeded.PeSupplierRowIds[0],
|
||||||
|
"SupplierId là `w.SupplierId` (master) chứ KHÔNG phải `w.Id` (row của phiếu PE) — "
|
||||||
|
+ "nhầm 2 cái này thì dòng giá trỏ vào một NCC không tồn tại, tên hiện rỗng ở FE");
|
||||||
|
lines.Should().NotContain(l => l.SupplierId == seeded.MasterSupplierIds[2],
|
||||||
|
"NCC thua thầu KHÔNG được có dòng giá dù báo giá của nó vẫn IsSelected");
|
||||||
|
|
||||||
|
// ---- Số tiền chốt: từng đồng, không dùng biểu thức lặp lại logic sản xuất.
|
||||||
|
var bySupplier = plan.Lines.ToDictionary(l => l.SupplierId);
|
||||||
|
bySupplier[seeded.MasterSupplierIds[0]].PeReferenceAmount.Should().Be(140m,
|
||||||
|
"winner0 = 100 + 40 (cả 2 hạng mục được chọn)");
|
||||||
|
bySupplier[seeded.MasterSupplierIds[1]].PeReferenceAmount.Should().Be(200m,
|
||||||
|
"winner1 = 200; hạng mục 60 KHÔNG được chọn nên KHÔNG cộng — nếu ra 260 là quên lọc IsSelected");
|
||||||
|
|
||||||
|
// Ngân sách hạng mục là 1 tỷ/dòng: nếu ai lỡ lấy ThanhTienNganSach thì con số sẽ
|
||||||
|
// nhảy sang hàng tỷ, không thể trùng 140/200 do ngẫu nhiên.
|
||||||
|
plan.Lines.Sum(l => l.PeReferenceAmount).Should().Be(340m);
|
||||||
|
|
||||||
|
// ---- D4: mồi ProposedAmount = số tham chiếu (W2 chưa có đường sửa dòng giá).
|
||||||
|
plan.Lines.Should().OnlyContain(l => l.ProposedAmount == l.PeReferenceAmount,
|
||||||
|
"đề xuất mồi bằng giá đã duyệt ở PE — để 0 thì cả phiếu là 0 mà W2 không ai sửa được");
|
||||||
|
plan.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "giá chốt chỉ có ở W3");
|
||||||
|
|
||||||
|
// ---- Vết audit: 1 dòng nhật ký Insert, có trỏ ngược về phiếu nguồn.
|
||||||
|
var logs = await db.ContractSigningPlanChangelogs.AsNoTracking()
|
||||||
|
.Where(c => c.ContractSigningPlanId == plan.Id).ToListAsync();
|
||||||
|
logs.Should().ContainSingle();
|
||||||
|
logs[0].Action.Should().Be(ChangelogAction.Insert);
|
||||||
|
logs[0].EntityType.Should().Be(ContractSigningPlanEntityType.Header);
|
||||||
|
logs[0].PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
|
||||||
|
logs[0].UserId.Should().Be(f.Actor.UserId);
|
||||||
|
logs[0].ContextNote.Should().Contain(seeded.PeId.ToString(),
|
||||||
|
"nhật ký phải truy ngược được về phiếu Duyệt NCC nguồn");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (3) 1 phiếu PE = 1 kế hoạch SỐNG (`:323-328`) — acceptance "tạo lần 2 → 409".
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateContractSigningPlan_SecondTimeForSamePe_Throws_AndDoesNotBurnCode()
|
||||||
|
{
|
||||||
|
using var f = new KhkkFixture();
|
||||||
|
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||||
|
|
||||||
|
var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
first.MaKeHoach.Should().Be("KHKK/2026/001");
|
||||||
|
|
||||||
|
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"1 phiếu Duyệt NCC chỉ được có 1 kế hoạch ký kết đang sống");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||||
|
.Should().Be(1, "lần 2 không được đẻ thêm phiếu");
|
||||||
|
|
||||||
|
// Rào (iii) đứng TRƯỚC codegen ⇒ lần thất bại không nuốt mất số 002.
|
||||||
|
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
|
||||||
|
.Should().Be(1, "lần tạo hỏng KHÔNG được tiêu một số thứ tự (mã sẽ thủng lỗ chỗ)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (4) Rào workflow-type — TRỌN 2 nhánh (`:197-200`).
|
||||||
|
//
|
||||||
|
// Đây là chỗ dễ "copy nửa guard" nhất: bản gốc Proposal có 2 nhánh, chép thiếu nhánh
|
||||||
|
// NotFound thì một Guid quy trình rác đi thẳng vào phiếu, và chỉ vỡ ở W3 lúc duyệt.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateContractSigningPlan_WorkflowGuard_WrongType_Conflict_And_Unknown_NotFound()
|
||||||
|
{
|
||||||
|
// (a) Quy trình CÓ THẬT nhưng thuộc module khác (Duyệt NCC) → Conflict.
|
||||||
|
using var wrong = new KhkkFixture();
|
||||||
|
var wrongSeed = await SeedPeAsync(wrong,
|
||||||
|
supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]],
|
||||||
|
wfType: ApprovalWorkflowApplicableType.DuyetNcc);
|
||||||
|
|
||||||
|
var actWrong = async () => await CreateAsync(wrong, wrongSeed.PeId, wrongSeed.WorkflowId);
|
||||||
|
(await actWrong.Should().ThrowAsync<ConflictException>(
|
||||||
|
"quy trình của module khác không được pin vào phiếu KHKK"))
|
||||||
|
.WithMessage("*Kế hoạch ký kết*");
|
||||||
|
|
||||||
|
// (b) Quy trình KHÔNG tồn tại → NotFound (KHÔNG được im lặng cho qua).
|
||||||
|
using var missing = new KhkkFixture();
|
||||||
|
var okSeed = await SeedPeAsync(missing, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||||
|
|
||||||
|
var actMissing = async () => await CreateAsync(missing, okSeed.PeId, Guid.NewGuid());
|
||||||
|
await actMissing.Should().ThrowAsync<NotFoundException>(
|
||||||
|
"Guid quy trình rác phải chặn NGAY — lọt vào phiếu thì tới W3 mới vỡ, lúc đó phiếu đã có mã");
|
||||||
|
|
||||||
|
await using var db = missing.NewDb();
|
||||||
|
db.ContractSigningPlans.IgnoreQueryFilters().Count().Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (5) Chưa chọn NCC trúng thầu (`:317-318`) — phiếu 0 dòng giá là phiếu vô nghĩa.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateContractSigningPlan_NoWinner_Throws()
|
||||||
|
{
|
||||||
|
using var f = new KhkkFixture();
|
||||||
|
var seeded = await SeedPeAsync(f,
|
||||||
|
supplierCount: 3,
|
||||||
|
winnerIndexes: [], // PE đã duyệt nhưng chưa tick NCC nào trúng
|
||||||
|
quotes: [[100m, 200m, 300m]],
|
||||||
|
selected: [[true, true, true]]); // có báo giá được chọn — vẫn KHÔNG đủ
|
||||||
|
|
||||||
|
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
(await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"không có NCC trúng thầu thì không có dòng giá nào để chốt"))
|
||||||
|
.WithMessage("*trúng thầu*");
|
||||||
|
|
||||||
|
await using var db = f.NewDb();
|
||||||
|
db.ContractSigningPlans.IgnoreQueryFilters().Count()
|
||||||
|
.Should().Be(0, "KHÔNG được tạo phiếu rỗng 0 dòng");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (6) Acceptance §③-B: "DELETE ở Nháp → 204; tạo lại cho CÙNG phiếu PE → 201".
|
||||||
|
//
|
||||||
|
// Đo 3 điều CÙNG LÚC, vì chỉ đo "tạo lại được" là đo yếu nhất trong ba:
|
||||||
|
// (a) xoá là xoá MỀM (row còn, `IsDeleted=1`) — nếu là xoá cứng thì màn "Đã xoá" rỗng;
|
||||||
|
// (b) rào "1 PE = 1 kế hoạch sống" thoát được nhờ GLOBAL QUERY FILTER, không phải
|
||||||
|
// nhờ row biến mất ⇒ đây mới là cửa thoát khoá 409 vĩnh viễn (M6);
|
||||||
|
// (c) 🔴 dòng nhật ký "xoá" PHẢI tồn tại — bug T26 (S155) là: `Add(changelog)` đặt
|
||||||
|
// TRƯỚC `Remove(cha)` thì EF cascade client-side tại `Remove()` DETACH luôn
|
||||||
|
// dòng đang `Added` ⇒ SaveChanges ghi 0 row, IM LẶNG, không lỗi. Bản W2 đã đảo
|
||||||
|
// đúng thứ tự (`:1126-1128`); vế (c) khoá lại để không ai "dọn cho gọn" ngược về.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds()
|
||||||
|
{
|
||||||
|
using var f = new KhkkFixture();
|
||||||
|
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||||
|
|
||||||
|
var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
await DeleteAsync(f, first.Id, "Lập nhầm phiếu nguồn", f.Actor);
|
||||||
|
|
||||||
|
await using (var db = f.NewDb())
|
||||||
|
{
|
||||||
|
// (a) xoá MỀM: khuất khỏi mọi query thường, nhưng row vẫn nằm đó.
|
||||||
|
db.ContractSigningPlans.Count(p => p.Id == first.Id)
|
||||||
|
.Should().Be(0, "phiếu đã xoá không được hiện ở danh sách");
|
||||||
|
var raw = db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
|
||||||
|
.Single(p => p.Id == first.Id);
|
||||||
|
raw.IsDeleted.Should().BeTrue("`Remove()` + AuditingInterceptor = xoá MỀM, không xoá cứng");
|
||||||
|
raw.DeletedAt.Should().NotBeNull();
|
||||||
|
raw.DeletedBy.Should().Be(f.Actor.UserId);
|
||||||
|
raw.MaKeHoach.Should().Be("KHKK/2026/001", "mã cũ vẫn giữ trong row đã xoá");
|
||||||
|
|
||||||
|
// (c) vết xoá phải SỐNG SÓT qua lượt cascade — regression bug T26 S155.
|
||||||
|
var logs = db.ContractSigningPlanChangelogs.AsNoTracking()
|
||||||
|
.Where(c => c.ContractSigningPlanId == first.Id).ToList();
|
||||||
|
logs.Should().HaveCount(2, "1 dòng lúc lập + 1 dòng lúc xoá");
|
||||||
|
var del = logs.Single(l => l.Action == ChangelogAction.Delete);
|
||||||
|
del.ContextNote.Should().Be("Lập nhầm phiếu nguồn", "lý do xoá nằm ở nhật ký");
|
||||||
|
del.PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
|
||||||
|
del.UserId.Should().Be(f.Actor.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) Cửa thoát: lập LẠI cho CHÍNH phiếu PE đó.
|
||||||
|
var second = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
second.Id.Should().NotBe(first.Id);
|
||||||
|
second.MaKeHoach.Should().Be("KHKK/2026/002", "phiếu mới lấy số tiếp theo, không tái dùng mã cũ");
|
||||||
|
second.LineCount.Should().Be(1);
|
||||||
|
|
||||||
|
await using var check = f.NewDb();
|
||||||
|
check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||||
|
.Should().Be(1, "chỉ 1 kế hoạch SỐNG cho phiếu PE này");
|
||||||
|
check.ContractSigningPlans.IgnoreQueryFilters().Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||||
|
.Should().Be(2, "cộng cả phiếu đã xoá mềm là 2 row vật lý");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (7) Rào xoá: chủ phiếu ∥ Admin, và CHỈ ở {Nháp, Từ chối} (`:1107-1118`).
|
||||||
|
// Phiếu đang duyệt phải Trả lại/Từ chối trước — xoá giữa chừng là mất lượt
|
||||||
|
// của người đang chờ duyệt (đường xoá-khi-đang-duyệt là việc của W3).
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteContractSigningPlan_GuardedByOwnerAndPhase()
|
||||||
|
{
|
||||||
|
using var f = new KhkkFixture();
|
||||||
|
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||||
|
var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||||
|
|
||||||
|
// (a) người lạ (không phải chủ phiếu, không Admin) → Forbidden.
|
||||||
|
var stranger = new TestCurrentUser(Guid.NewGuid(), "Người lạ", "la@test.local");
|
||||||
|
var actStranger = async () => await DeleteAsync(f, created.Id, null, stranger);
|
||||||
|
await actStranger.Should().ThrowAsync<ForbiddenException>(
|
||||||
|
"chỉ người soạn kế hoạch hoặc Admin được xoá");
|
||||||
|
|
||||||
|
await using (var db = f.NewDb())
|
||||||
|
{
|
||||||
|
db.ContractSigningPlans.Count(p => p.Id == created.Id)
|
||||||
|
.Should().Be(1, "chặn xong phiếu phải còn NGUYÊN (ném rồi mà vẫn ghi = tệ hơn không rào)");
|
||||||
|
|
||||||
|
// (b) đẩy phiếu sang Đã gửi duyệt (W2 chưa có transition ⇒ set thẳng DB).
|
||||||
|
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
|
||||||
|
plan.Phase = ContractSigningPlanPhase.ChoDuyet;
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var actPhase = async () => await DeleteAsync(f, created.Id, null, f.Actor);
|
||||||
|
(await actPhase.Should().ThrowAsync<ConflictException>(
|
||||||
|
"phiếu đang chờ duyệt thì chủ phiếu cũng không được rút êm"))
|
||||||
|
.WithMessage("*Nháp*");
|
||||||
|
|
||||||
|
// (c) VẾ THUẬN: Admin không-phải-chủ-phiếu + đưa phiếu về Từ chối (nằm trong
|
||||||
|
// allow-list) → xoá ĐƯỢC. Không có vế này thì 2 vế trên vẫn xanh trong thế
|
||||||
|
// giới mà handler ném với MỌI đầu vào.
|
||||||
|
await using (var db = f.NewDb())
|
||||||
|
{
|
||||||
|
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
|
||||||
|
plan.Phase = ContractSigningPlanPhase.TuChoi;
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
|
||||||
|
await DeleteAsync(f, created.Id, "Dọn phiếu bị từ chối", admin);
|
||||||
|
|
||||||
|
await using var final = f.NewDb();
|
||||||
|
final.ContractSigningPlans.Count(p => p.Id == created.Id).Should().Be(0);
|
||||||
|
final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
|
||||||
|
.Single(p => p.Id == created.Id).DeletedBy
|
||||||
|
.Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user