[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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user