[CLAUDE] App+Infra+Api+FE-User+FE-Admin+Tests: W3 KHKK — duyet 3 tram OR-of-N + finalize chot gia (test-before 6 RED->GREEN)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s

- ContractSigningPlanWorkflowService: copy khuon ApproveV2Async (con-tro doi StepIndex+LevelOrder, GroupBy Order, OR-of-N, LevelOpinions UPSERT "(duyet — khong y kien)") — CAM Proposal-flatten; KHONG port 2 duong ket-thuc-som (owner phan PHA VO S155)
- Choke-point finalize ApplyApprovedValuesOnFinalize (Line.ApprovedAmount ??= Proposed) 1-SITE-BY-DESIGN: hop-dong transitions khong co targetPhase => admin cung xuyen choke-point (triet lop #81 tu goc); RULE-comment khuon :356-360
- Transitions endpoint: submit/approve/return/reject (hop-dong LEAD-dinh-nghia literal — reviewer Truc-4 KHOP 5/5, truc W2 tung dut 6 diem); guard trinh CreatedBy|Drafter|DeptManager-cung-phong|Admin; changelog/approvals bang KHKK rieng (CAM IChangelogService — FK-547 rao bang vang-mat-dep); notify Cap-ke dich danh + exclusion actor/drafter (S86)
- Save-diem: 1 SaveChanges cuoi = 1 transition 1 unit-of-work (service la diem vao, khac nguon :537)
- FE KhkkWorkflowPanel x2 app SHA-pair: so-do Buoc/Cap + banner den-luot + Gui/Duyet/Tra/Tu-choi + y kien; panel GATE nut theo actor (khong de an 403)
- Test-before: 6 test san PIN RED-evidence 6/6 (5 NotImplemented + 1 assert) -> GREEN 6/6; sua-test dung 1 seed FAULT-INJECT sot (0 assert doi); suite 574 -> 580/0
- Reviewer 7/7 truc DAT 0 chan (4 truc vai + 3 truc lead on-behalf; Q1-Q6 duyet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 22:33:11 +07:00
parent d7bdb45312
commit df52fa0cd0
11 changed files with 2370 additions and 6 deletions

View File

@ -6,7 +6,8 @@
// 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.
// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"):
// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối.
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
@ -31,6 +32,7 @@ import {
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
@ -316,6 +318,11 @@ export function KhkkDetailPage() {
}
/>
{/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm
trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong
màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */}
<KhkkWorkflowPanel plan={k} onChanged={invalidate} />
{/* Section 1: Thông tin */}
<SectionCard
title="Thông tin kế hoạch"

View File

@ -0,0 +1,423 @@
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
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 { useAuth } from '@/contexts/AuthContext'
import {
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
} from '@/types/khkk'
type NodeStatus = 'Done' | 'Current' | 'Pending'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
const STATUS_DOT: Record<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
Done: 'border-emerald-200 bg-emerald-50/40',
Current: 'border-brand-300 bg-brand-50/50',
Pending: 'border-slate-200 bg-white',
}
const STATUS_ICON: Record<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
export function KhkkWorkflowPanel({
plan,
onChanged,
}: {
plan: KhkkDetailDto
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
onChanged?: () => void
}) {
const qc = useQueryClient()
const { user } = useAuth()
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
const [comment, setComment] = useState('')
const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? []
const opinions = plan.levelOpinions ?? []
const isWaiting = phase === KhkkPhase.ChoDuyet
const isApproved = phase === KhkkPhase.DaDuyet
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const curStepIdx = plan.currentWorkflowStepIndex
const curLevelOrder = plan.currentApprovalLevelOrder
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
const stepStatus = (idx: number): NodeStatus => {
if (isApproved) return 'Done'
if (!isWaiting || curStepIdx == null) return 'Pending'
if (idx < curStepIdx) return 'Done'
if (idx === curStepIdx) return 'Current'
return 'Pending'
}
const levelStatus = (idx: number, order: number): NodeStatus => {
const s = stepStatus(idx)
if (s !== 'Current' || curLevelOrder == null) return s
if (order < curLevelOrder) return 'Done'
if (order === curLevelOrder) return 'Current'
return 'Pending'
}
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
const currentLevels: KhkkWorkflowLevelDto[] =
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
: []
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
? steps[curStepIdx]
: null
const isAdmin = user?.roles?.includes('Admin') ?? false
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor DeptManager(cùng phòng) Admin`
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
const transition = useMutation({
mutationFn: async (a: KhkkTransitionActionValue) => {
// 🔴 BODY LITERAL theo hợp-đồng LEAD chốt: đúng 2 field `action` + `comment`.
const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null }
const res = await api.post<KhkkTransitionResult>(
`/contract-signing-plans/${plan.id}/transitions`,
body,
)
return res.data
},
onSuccess: (res) => {
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
setAction(null)
setComment('')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
onChanged?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const dialogTitle =
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
: '✗ Từ chối kế hoạch ký kết'
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
const commentRequired =
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-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-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
)}
</header>
<div className="space-y-4 p-5">
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex items-center gap-2 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
</div>
{step.levels.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{step.levels.map(lv => {
const ls = levelStatus(idx, lv.order)
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<li key={lv.id} className="text-[11px]">
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{lv.name || `Cấp ${lv.order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
</div>
<div className="text-slate-500">
{lv.approverFullName ?? '(chưa cấu hình)'}
{signed && (
<span className="ml-1.5 text-[10px] text-emerald-600">
{formatDateTime(signed.signedAt)}
</span>
)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Quy trình duyệt chưa cấu hình Bước/Cấp liên hệ Admin đ dựng quy trình loại
&ldquo;Kế hoạch kết &rdquo; trước khi trình duyệt.
</div>
)}
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{phase === KhkkPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
Kế hoạch bị TRẢ LẠI sửa nội dung rồi bấm &ldquo;Gửi duyệt&rdquo;, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{phase === KhkkPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch đã bị TỪ CHỐI không thao tác đưc nữa. Lập kế hoạch mới nếu cần làm lại.
</div>
)}
{/* Hành động */}
{(canSubmit || isWaiting) && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{isWaiting && (
<>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Approve) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
<Check className="h-3 w-3" /> Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-red-300 text-red-700 hover:bg-red-50',
)}
>
<X className="h-3 w-3" /> Từ chối
</button>
</>
)}
</div>
</div>
)}
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
{opinions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
Ý kiến cấp duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{opinions.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{opinions.map(o => (
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{o.signedByFullName}</span>
<span className="text-slate-400">·</span>
<span>
{o.stepName ? `Bước ${o.stepOrder}${o.stepName}` : 'Cấp duyệt'}
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay
</span>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{o.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
</li>
))}
</ul>
</div>
)}
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === KhkkTransitionAction.Submit && (
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
Kế hoạch chuyển sang &ldquo;Chờ duyệt&rdquo; trình lên Bước 1 · Cấp 1 của quy trình
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa đưc nữa.
</div>
)}
{action === KhkkTransitionAction.Return && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Kế hoạch về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
{action === KhkkTransitionAction.Reject && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
</div>
)}
<Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
/>
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ).
</p>
)}
</Dialog>
)}
</section>
)
}

View File

@ -20,7 +20,10 @@
// 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.)
//
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
// policy `KeHoachKyKet.Update`
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
export const KhkkPhase = {
@ -161,12 +164,36 @@ export interface KhkkLevelOpinionDto {
stepOrder: number | null
stepName: string | null
levelOrder: number | null
// [W3 S161] BE `ContractSigningPlanLevelOpinionDto:99` CÓ field này (W2 khai thiếu).
// `signedByUserId !== approverUserId` ⇒ có người duyệt THAY (Admin) — panel gắn nhãn.
approverUserId: string | null
comment: string | null
signedAt: string
signedByUserId: string
signedByFullName: string
}
// [W3 S161] Cây quy trình THÔ do BE trả (`ContractSigningPlanWorkflowLevelDto:105-110`).
// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending — FE tự suy từ cặp
// con-trỏ `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder`
// (GIÁ TRỊ `level.order`). Xem `ContractSigningPlanFeatures.cs:224-226` + `:259-260`.
export interface KhkkWorkflowLevelDto {
id: string
order: number
name: string | null
approverUserId: string
approverFullName: string | null
}
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
export interface KhkkWorkflowStepDto {
id: string
order: number
name: string
departmentId: string | null
levels: KhkkWorkflowLevelDto[]
}
export interface KhkkDetailDto extends KhkkListItemDto {
workflowName: string | null
currentWorkflowStepIndex: number | null
@ -177,6 +204,8 @@ export interface KhkkDetailDto extends KhkkListItemDto {
dossierItems: KhkkDossierItemDto[]
attachments: KhkkAttachmentDto[]
levelOpinions: KhkkLevelOpinionDto[]
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
workflowSteps: KhkkWorkflowStepDto[]
}
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
@ -218,6 +247,34 @@ export interface UpsertKhkkDossierItemInput {
note?: string | null
}
// ===================== W3 — chuyển trạng thái (duyệt 3 trạm) =========================
// 🔴 HỢP-ĐỒNG LITERAL do LEAD chốt (S161 W3). Tên field ở đây = tên field BE bind.
// Đổi 1 chữ ở đây mà không đổi BE = đứt im lặng (bài W2 6-điểm-đứt).
// submit = trình duyệt (DangSoanThao | TraLai → ChoDuyet)
// approve = duyệt cấp hiện tại (OR-of-N; comment ≠ rỗng ⇒ UPSERT LevelOpinion)
// return = trả lại (→ TraLai)
// reject = từ chối (→ TuChoi)
export const KhkkTransitionAction = {
Submit: 'submit',
Approve: 'approve',
Return: 'return',
Reject: 'reject',
} as const
export type KhkkTransitionActionValue =
(typeof KhkkTransitionAction)[keyof typeof KhkkTransitionAction]
export interface KhkkTransitionInput {
action: KhkkTransitionActionValue
comment: string | null
}
// Response 200 — 3 field, ĐÚNG tên hợp-đồng. `phase` là INT (mirror `KhkkPhase`).
export interface KhkkTransitionResult {
phase: number
currentWorkflowStepIndex: number | null
currentApprovalLevelOrder: number | null
}
export interface PagedResult<T> {
items: T[]
total: number