[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -2,14 +2,20 @@
|
||||
// Khuôn `pages/office/ProposalCreatePage.tsx` (PURO: ui/PageHeader + section
|
||||
// .card-accent + .icon-chip header). File MIRROR SHA256 identical với fe-admin.
|
||||
//
|
||||
// 3 section: (1) chọn phiếu PE nguồn — chỉ phiếu DaDuyet CHƯA có kế hoạch sống
|
||||
// (BE lọc, FE không tự đoán); (2) quy trình duyệt V2 ApplicableType=10 admin đã
|
||||
// ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
||||
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE
|
||||
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin
|
||||
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE
|
||||
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines.
|
||||
//
|
||||
// [K2 S164 — OG-7 UI-hướng-1] ĐỔI HÌNH DẠNG màn này: trước K2, PE đã có kế hoạch bị
|
||||
// BE loại khỏi picker (biến mất) và tạo trùng thì ăn 409 chặn hẳn. Từ K2, rào là
|
||||
// "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm danh sách nhóm đã dùng + nút phụ
|
||||
// "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). Ẩn PE đi thì người dùng
|
||||
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CheckCircle2, FileCheck, FileText, Save, Settings2, X } from 'lucide-react'
|
||||
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -18,7 +24,13 @@ import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { CreateKhkkInput, KhkkPeCandidateDto } from '@/types/khkk'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUPS,
|
||||
KHKK_APPROVAL_GROUP_DEFAULT,
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
type CreateKhkkInput,
|
||||
type KhkkPeCandidateDto,
|
||||
} from '@/types/khkk'
|
||||
|
||||
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
|
||||
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1).
|
||||
@ -36,14 +48,23 @@ function formatDate(iso: string): string {
|
||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
|
||||
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
|
||||
// một nhóm chắc chắn 409.
|
||||
function firstFreeGroup(taken: number[]): number | null {
|
||||
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
|
||||
}
|
||||
|
||||
export function KhkkCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
|
||||
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT)
|
||||
const [ghiChu, setGhiChu] = useState('')
|
||||
|
||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ chưa có kế hoạch sống (BE lọc,
|
||||
// mirror `ListApprovedPurchaseEvaluationsQuery` + vị ngữ chưa-có-plan).
|
||||
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp
|
||||
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
|
||||
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
|
||||
const candidates = useQuery({
|
||||
queryKey: ['khkk-pe-candidates'],
|
||||
queryFn: async () =>
|
||||
@ -73,6 +94,9 @@ export function KhkkCreatePage() {
|
||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||
approvalWorkflowId,
|
||||
ghiChu: ghiChu.trim() || null,
|
||||
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã
|
||||
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
|
||||
approvalGroup,
|
||||
}
|
||||
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
|
||||
return res.data
|
||||
@ -91,6 +115,24 @@ export function KhkkCreatePage() {
|
||||
|
||||
const list = candidates.data ?? []
|
||||
|
||||
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
|
||||
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
|
||||
// cũ còn nằm trong cache React Query trước khi K2 deploy.
|
||||
const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
|
||||
const takenGroups = selectedPe?.existingGroups ?? []
|
||||
const groupTaken = takenGroups.includes(approvalGroup)
|
||||
|
||||
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã
|
||||
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
|
||||
const selectPe = (pe: KhkkPeCandidateDto) => {
|
||||
setPurchaseEvaluationId(pe.id)
|
||||
const taken = pe.existingGroups ?? []
|
||||
if (taken.includes(approvalGroup)) {
|
||||
const free = firstFreeGroup(taken)
|
||||
if (free !== null) setApprovalGroup(free)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
@ -126,17 +168,21 @@ export function KhkkCreatePage() {
|
||||
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>}
|
||||
{!candidates.isLoading && list.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground">
|
||||
Không có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký kết.
|
||||
Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu.
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{list.map((pe) => {
|
||||
const selected = pe.id === purchaseEvaluationId
|
||||
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
|
||||
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
|
||||
const taken = pe.existingGroups ?? []
|
||||
const free = firstFreeGroup(taken)
|
||||
return (
|
||||
<div key={pe.id} className="space-y-1">
|
||||
<button
|
||||
key={pe.id}
|
||||
type="button"
|
||||
onClick={() => setPurchaseEvaluationId(pe.id)}
|
||||
onClick={() => selectPe(pe)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
|
||||
selected
|
||||
@ -172,6 +218,33 @@ export function KhkkCreatePage() {
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm
|
||||
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng
|
||||
button là HTML không hợp lệ). */}
|
||||
{taken.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs">
|
||||
<span className="text-slate-500">Đã có kế hoạch nhóm:</span>
|
||||
{taken.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||
>
|
||||
N{g}
|
||||
</span>
|
||||
))}
|
||||
{free === null ? (
|
||||
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Tạo thêm cho nhóm khác
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@ -188,27 +261,64 @@ export function KhkkCreatePage() {
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Nhóm duyệt & quy trình</h3>
|
||||
</header>
|
||||
<div className="space-y-2 p-5">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="workflow"
|
||||
value={approvalWorkflowId}
|
||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{workflows.data?.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
||||
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
|
||||
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP,
|
||||
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable —
|
||||
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="approvalGroup" className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 text-violet-600" />
|
||||
Nhóm duyệt (N1–N8) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="approvalGroup"
|
||||
value={approvalGroup}
|
||||
onChange={(e) => setApprovalGroup(Number(e.target.value))}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
{KHKK_APPROVAL_GROUPS.map((g) => {
|
||||
const used = takenGroups.includes(g)
|
||||
return (
|
||||
<option key={g} value={g} disabled={used}>
|
||||
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
|
||||
{used ? ' (đã có kế hoạch)' : ''}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{groupTaken ? (
|
||||
<div className="mt-1 text-xs font-medium text-red-600">
|
||||
Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm N{approvalGroup} — chọn nhóm khác.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
id="workflow"
|
||||
value={approvalWorkflowId}
|
||||
onChange={(e) => setApprovalWorkflowId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{workflows.data?.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -243,7 +353,12 @@ export function KhkkCreatePage() {
|
||||
<Button type="button" variant="outline" onClick={() => navigate('/khkk/list')}>
|
||||
Huỷ
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId}>
|
||||
{/* [K2 S164] `groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
|
||||
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ là đỡ 1 vòng mạng. */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending || !purchaseEvaluationId || !approvalWorkflowId || groupTaken}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user