Files
solution-erp/fe-user/src/pages/khkk/KhkkDetailPage.tsx
pqhuy1987 99cabd8ebe
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
[CLAUDE] Contract: KHKK UAT S175 dot-F — gom nut thao tac + hang muc du an + loc nhom (0-mig)
Anh review truc tiep tren prod, khoanh do tung cho. Land tron cum:

=== 1. GOM NUT THAO TAC (anh: "Bo het di" + "phia duoi them cac nut thao tac"
       + "cho menu dang duyet -> Duyet/Tra lai vay thoi") ===
· THANH NUT DAY moi o `KhkkDetailContent` (sticky bottom-0, khuon PE workspace):
  [Xoa phieu] · dong "Cac thay doi da tu dong luu" · [Luu & Gui duyet ->]
· Panel 3 GO "Gui duyet" + "Xoa phieu" => nay CHI con **Duyet / Tra lai**.
· GO nut "+ Lap ke hoach" (header) · nut "Sua" (muc 1) · dai banner "Dang xem ke hoach".
🔴 THU TU CO CHU DICH: KHONG go "Gui duyet" o panel 3 TRUOC khi thanh day co mat — go truoc
  la nguoi soan mat duong trinh duyet, phieu ket o Nhap. Vi vay ca cum di CHUNG 1 commit.
🔴 Thanh day chi om nhanh **Submit** — nhanh Approve/Return (o-tich level-finalize + upload
  file sau transition) O LAI panel 3. Nhan ban nhanh do sang day la de su-that thu hai cho
  duong DUYET.
🔸 KHONG lam nut "Luu" roi: moi o o man nay luu NGAY khi roi o. Them nut khong lam gi la
  hua hao — noi thang bang dong chu ben canh thay vi ve nut gia.
🔸 Giu lai dung 1 dong nho "Ve form tao" — bo han thi mo 1 phieu la ket, khong co duong lui.
· `onDeleted` noi vao ca 3 host (List / Create / Detail) de xoa xong khong dung o phieu ma.

=== 2. HANG MUC CUA DU AN (anh: "hang muc cua Du an -. Duyet NCC ay") ===
🔴 Lead bay NHAM o luot truoc: bay `line.tenHangMuc` = danh-muc **SP-002** (86 dong, dung
  phan loai HOP DONG). Anh muon **hang muc CONG VIEC cua du an** tu phieu Duyet NCC
  (`PE.WorkItemId`). Hai thu trung ten tieng Viet nhung KHAC TRUC — tron la chi sai goi thau.
· BE: `ContractSigningPlanDetailDto` +`PeWorkItemName`; handler join `WorkItems` (loose-Guid
  Mig 49, KHONG FK vat ly => join tay null-safe).
· FE: o "Hang muc (du an)" doc `plan.peWorkItemName`; go het khoi liet-ke SP-002 khoi muc 1.

=== 3. MUC 2 RUT VE "HANG MUC + GIA -> TONG" ===
Anh: "Cho nay doi lai la hang muc la dc roi, chon hang muc tien -> thanh tong con lai bo het"
 + "cho chon nhieu dong nhap gia thanh gia tong, ko can so sanh j voi so duyet PRO hay CCM".
· Tieu de: "2. Gia de xuat theo NCC trung thau" -> "2. Hang muc".
· Cot: 6 -> 3 (HANG MUC | GIA | thao-tac). GO **Nha cung cap · Gia tham chieu · Gia duyet**.
· Ghi chu xuong DONG PHU duoi o hang muc (van nhap duoc, khong chiem cot).
· +hang TONG = Sigma gia cac dong.
🔸 Du-lieu 2 cot go di VAN trong DTO; `peReferenceAmount` con dung lam DAU phan biet
  dong-goc / dong-them-tay o nut xoa.

=== 4. LOC THEO NHOM (anh: "Loc theo quy trinh luon") ===
Tang duoi man Thao tac hien phieu N1/N7 trong khi dang dung o leaf N2 => doc nham "nhom nay
dang co viec". Nay loc theo `approvalGroup` cua leaf menu. `group == null` => khong loc
(giu duong cu cho host khong truyen).

=== 5. GO DAI ACCENT + 2 O LINK HO SO/GHI CHU ===
· `card-accent` boc `KhkkDetailContent` GO HAN (dai xanh doc man Thao tac). GO CLASS chu
  KHONG de `border-0` — class nay ngoai `@layer` nen thang utility (gotcha #66).
· Khoi "Link ho so + Ghi chu" GO TRON ca 2 nhanh ternary. Luot truoc lead moi go o luoi tren
  nen 2 o van hien o khoi duoi — anh chup lai dung cho do.

=== CON NO ===
🔴 Cay man Thao tac: anh muon **Nam > Du an > Hang muc > 4 giai doan** (giong cay o man Danh
  sach). Hien moi co Du an > Hang muc (gap/mo duoc). Chua land.
🔴 CHUA soi mat prod 375/1366/1440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:47:12 +07:00

378 lines
18 KiB
TypeScript

// [W2 KHKK — S161 · dựng lại S171] Trang chi tiết "Kế hoạch ký kết HĐ" (GĐ2), route
// `/khkk/:id` GIỮ NGUYÊN (deep-link · bookmark · fallback `<lg` của màn 3-panel).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// [S171] Ruột trang nay là ĐÚNG 2 component dùng chung với host 3-panel `/khkk/list`:
// `components/khkk/KhkkDetailContent` (nội dung phiếu, 5 Section đánh số)
// `components/khkk/KhkkWorkflowPanel` (quy trình duyệt + lịch sử)
// xếp DỌC — khuôn `PurchaseEvaluationDetailPage` (`:798-805`). Không còn bản sao thứ hai
// của các Section ở đây: sửa 1 chỗ là cả 2 màn đổi theo.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { ArrowLeft, FileCheck, FileSignature } 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 { Select } from '@/components/ui/Select'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
import { useAuth } from '@/contexts/AuthContext'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { ContractTypeLabel } from '@/types/forms'
import {
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KhkkPhase,
type CreateContractFromKhkkInput, type CreateContractFromKhkkResult,
type KhkkDetailDto, type KhkkLineDto, type KhkkPhaseValue,
} from '@/types/khkk'
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
export function KhkkDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const { user } = useAuth()
const [searchParams] = useSearchParams()
const [bridgeOpen, setBridgeOpen] = useState(false)
// [M-10] Xóa/quay lại KHÔNG được rụng `?group=` — ném người dùng về "tất cả nhóm" là
// mất chỗ họ đang đứng. `?group=n` là ĐÚNG URL leaf của nhóm n nên sidebar vẫn sáng đúng.
const groupParam = searchParams.get('group')
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const backToList = group !== null ? `/khkk/list?group=${group}` : '/khkk/list'
// [K7 S167 — vá-1 vế FE] HỘI 2 KHÓA, đúng bằng server, KHÔNG lỏng hơn:
// · `KeHoachKyKet.Read` — cửa đọc chính phiếu này (`ContractSigningPlansController.cs:36`,
// `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key ⇒ KHÔNG OR `Khkk_*`, gotcha #85);
// · `Contracts.Create` — cửa của endpoint bắc cầu: tài nguyên SINH RA là Contract nên key
// phải là của endpoint đích, không phải của màn đang đứng.
const { can } = usePermission()
const canReadPlan = can('KeHoachKyKet', 'Read')
const canCreateContract = can('Contracts', 'Create')
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'] })
}
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(backToList)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
}
/>
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
Không tải đưc dữ liệu kế hoạch kết.
</div>
</div>
)
}
const k = plan.data
const phase = k.phase as KhkkPhaseValue
// [P2-7] `readOnly` là quyết-định của HOST (trang này cũng là một host).
const readOnly =
!(phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai)
|| (!!user?.id && k.drafterUserId !== user.id)
// [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE.
const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null)
const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan
// Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút (khuôn nút-khoá-kèm-title PE `:547-561`).
const bridgeBlockReason = !canCreateContract
? 'Bạn chưa có quyền "Tạo hợp đồng" (Contracts.Create) — nhờ quản trị cấp quyền rồi thử lại.'
: k.lines.length === 0
? 'Phiếu chưa có dòng nhà cung cấp nào.'
: bridgeableLines.length === 0
? (k.lines.every((l) => l.contractId != null)
? 'Mọi dòng của phiếu đã được đưa vào hợp đồng.'
: 'Chưa dòng nào có giá duyệt chốt — phiếu phải qua bước chốt giá trước khi đưa vào HĐ.')
: null
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={
<>
{k.approvalGroup != null && (
<span
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2.5 py-1 text-xs font-semibold text-violet-800"
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
>
N{k.approvalGroup}
</span>
)}
<span className={cn('inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium', KHKK_PHASE_BADGE[phase])}>
{KHKK_PHASE_LABELS[phase]}
</span>
{/* `<span title>` bọc ngoài vì `Button` có `disabled:pointer-events-none`
(Button.tsx:12) ⇒ title đặt thẳng trên nút đã khoá sẽ không hiện. */}
{showBridge && (
<span title={bridgeBlockReason ?? 'Tạo hợp đồng từ một dòng của kế hoạch này'}>
<Button variant="outline" disabled={bridgeBlockReason != null} onClick={() => setBridgeOpen(true)}>
<FileSignature className="mr-2 h-4 w-4" />
Đưa vào
</Button>
</span>
)}
<Button variant="outline" onClick={() => navigate(backToList)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Danh sách
</Button>
</>
}
/>
{/* [S174 owner] Khối "Quy trình duyệt" GỠ khỏi bản DESKTOP của trang này — anh chốt
"bỏ hết, nó sẽ hiển thị ở Menu duyệt". Nhà của nó ở desktop là Panel-3 màn danh sách
(`KhkkListPage.tsx:196`), nên gỡ = bỏ BẢN SAO THỨ HAI, không phải bỏ tính năng.
🔴 [A3] NHƯNG `<lg` KHÔNG có 3 panel: `selectPlan` điều hướng thẳng vào trang này
(`KhkkListPage.tsx:107`) ⇒ gỡ trắng thì mobile MẤT HẲN đường Gửi duyệt / Xóa phiếu.
⇒ giữ lại ĐÚNG ở `lg:hidden`: desktop vẫn sạch (panel-3 lo), mobile vẫn thao tác được.
Đây là nợ lead đã khai với owner ở vòng trước, nay trả. */}
<div className="lg:hidden">
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
</div>
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} onDeleted={() => navigate("/khkk/list")} />
{bridgeOpen && <CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />}
</div>
)
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng =================================
// [vá-16] 0 KHUÔN MỚI — ghép 2 khuôn đang sống:
// · vỏ dialog + khối "nói thẳng hệ quả" = nút Xóa-phiếu S155 (`PeWorkflowPanel.tsx:879-918`);
// · ô chọn Quy trình duyệt = picker `ContractCreatePage.tsx:333-343`.
// [OG-1 "máy rộng, UI hẹp"] Máy BE nhận `lineIds[]` (gộp được N dòng vào 1 HĐ); ô chọn ở
// đây CỐ Ý 1-1 — mỗi lần bấm đúng 1 dòng. Mở gộp sau = đổi radio thành checkbox, KHÔNG
// đụng hợp-đồng API.
type BridgeWorkflowOption = {
id: string
code: string
version: number
name: string
isActive: boolean
isUserSelectable: boolean
}
function CreateContractFromKhkkDialog({ plan, onClose }: { plan: KhkkDetailDto; onClose: () => void }) {
const navigate = useNavigate()
const qc = useQueryClient()
const bridgeable = plan.lines.filter((l) => l.contractId == null && l.approvedAmount != null)
const [lineId, setLineId] = useState(() => bridgeable[0]?.id ?? '')
const [contractType, setContractType] = useState(1)
const [workflowId, setWorkflowId] = useState('')
// null = "đang dùng tên mặc định theo dòng đang chọn". Giữ null thay vì đồng bộ bằng
// useEffect: đổi dòng thì tên gợi ý đổi theo NGAY trong render, không có nhịp lệch.
const [tenHopDongOverride, setTenHopDongOverride] = useState<string | null>(null)
const selected: KhkkLineDto | null = plan.lines.find((l) => l.id === lineId) ?? null
const defaultName = selected
? `${plan.peTenGoiThau ?? 'Kế hoạch ký kết'}${selected.tenHangMuc ?? selected.supplierName ?? ''}`.trim()
: ''
const tenHopDong = tenHopDongOverride ?? defaultName
const workflows = useQuery({
// Query key RIÊNG (không dùng lại `approval-workflows-v2-contract`): cùng URL nhưng LỌC
// KHÁC ⇒ dùng chung ô cache là để trang này ăn dữ liệu trang kia một cách câm (bài S159).
queryKey: ['khkk-bridge-contract-workflows'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: BridgeWorkflowOption[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const bucket = res.data.types.find((t) => t.applicableType === 3)
// 🔴 LỌC ĐỦ 2 CỜ: `isUserSelectable` (admin ghim cho user chọn) ∧ `isActive` (bản còn
// hiệu lực). Picker cũ chỉ lọc 1 cờ ⇒ mời chọn bản đã retire rồi ăn 409 từ guard BE.
return (bucket?.history ?? []).filter((w) => w.isUserSelectable && w.isActive)
},
})
const create = useMutation({
mutationFn: async () => {
const body: CreateContractFromKhkkInput = {
lineIds: [lineId],
contractType,
approvalWorkflowId: workflowId,
tenHopDong: tenHopDong.trim() || null,
}
return (await api.post<CreateContractFromKhkkResult>(
`/contract-signing-plans/${plan.id}/create-contract`, body,
)).data
},
onSuccess: (data) => {
toast.success(data.maHopDong ? `Đã tạo hợp đồng ${data.maHopDong}` : 'Đã tạo hợp đồng từ kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
// Cây 4 folder đọc 2 index riêng với `staleTime` 60s — không gọi 2 dòng này thì HĐ vừa
// tạo cả phút sau mới hiện trên cây.
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] })
onClose()
navigate(`/contracts/${data.contractId}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const noWorkflow = !workflows.isLoading && (workflows.data?.length ?? 0) === 0
return (
<Dialog
open
size="lg"
onClose={onClose}
title="Đưa dòng kế hoạch vào Hợp đồng"
footer={
<>
<Button variant="ghost" onClick={onClose}>Huỷ</Button>
<Button onClick={() => create.mutate()} disabled={create.isPending || !lineId || !workflowId}>
{create.isPending ? 'Đang tạo...' : 'Tạo hợp đồng'}
</Button>
</>
}
>
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-900">
<div className="font-semibold">Tạo hợp đng từ kế hoạch &ldquo;{plan.maKeHoach ?? 'chưa cấp mã'}&rdquo;</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Mỗi lần bấm tạo <strong>1 hợp đng cho 1 dòng</strong> (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.</li>
<li>Giá trị lấy đúng <strong>giá duyệt đã chốt</strong> của dòng không lấy giá đ xuất.</li>
<li> mới trạng thái <strong>Nháp</strong> đi theo quy trình duyệt bạn chọn dưới đây.</li>
<li>Dòng đã đưa vào thì <strong>không đưa lần hai</strong> cần sửa thì xử chính hợp đng đó.</li>
</ul>
</div>
<div className="space-y-3">
<div>
<Label>Chọn dòng đưa vào hợp đng</Label>
{plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu chưa dòng nhà cung cấp nào.</div>
) : (
<ul className="mt-1 space-y-1.5">
{plan.lines.map((l) => {
const done = l.contractId != null
const noPrice = l.approvedAmount == null
const disabled = done || noPrice
return (
<li key={l.id}>
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm',
disabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400',
!disabled && lineId === l.id && 'border-brand-400 bg-brand-50',
!disabled && lineId !== l.id && 'border-slate-200 hover:bg-slate-50',
)}
title={
done ? 'Dòng này đã có hợp đồng.'
: noPrice ? 'Dòng chưa có giá duyệt chốt — phiếu phải qua bước chốt giá.'
: undefined
}
>
<input
type="radio"
name="khkk-bridge-line"
checked={lineId === l.id}
disabled={disabled}
onChange={() => { setLineId(l.id); setTenHopDongOverride(null) }}
/>
<span className="min-w-0 flex-1">
<span className="font-medium">{l.tenHangMuc ?? '(chưa gán hạng mục)'}</span>
<span className="text-slate-500"> · {l.supplierName ?? '—'}</span>
</span>
<span className="shrink-0 tabular-nums">{formatVnd(l.approvedAmount)}</span>
{done && (
<a href={`/contracts/${l.contractId}`} className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline">
Đã
</a>
)}
{!done && noPrice && <span className="shrink-0 text-[11px]">Chưa giá chốt</span>}
</label>
</li>
)
})}
</ul>
)}
</div>
<div>
<Label htmlFor="bridge-type">Loại hợp đng</Label>
<Select id="bridge-type" value={contractType} onChange={(e) => setContractType(Number(e.target.value))}>
{Object.entries(ContractTypeLabel).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</Select>
</div>
<div>
<Label htmlFor="bridge-workflow">Quy trình duyệt hợp đng</Label>
<Select
id="bridge-workflow"
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
disabled={workflows.isLoading || noWorkflow}
>
<option value=""> Chọn quy trình </option>
{(workflows.data ?? []).map((w) => (
<option key={w.id} value={w.id}>{w.code} v{w.version} {w.name}</option>
))}
</Select>
{noWorkflow && (
// Rỗng-vì-chưa-cấu-hình phải khác câu với rỗng-vì-đang-tải: người dùng cần biết
// đây là việc của quản trị, không phải chờ thêm.
<p className="mt-1 text-[11px] text-red-700">
Chưa quy trình duyệt hợp đng nào đưc ghim cho người dùng chọn (cần đang hiệu lực
đưc ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại.
</p>
)}
</div>
<div>
<Label htmlFor="bridge-name">Tên hợp đng</Label>
<Input id="bridge-name" value={tenHopDong} onChange={(e) => setTenHopDongOverride(e.target.value)} maxLength={500} />
<p className="mt-1 text-[11px] text-slate-500">
Bỏ trống thì hệ thống tự đt theo tên gói thầu hạng mục của dòng.
</p>
</div>
</div>
</Dialog>
)
}