Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang
dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho
list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech
duy nhat trong 4 giai-doan truoc dot nay.
BE (7/7):
- DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName
/ FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append —
ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay
workflow da nap, thay vi port 3 subquery cua PE).
- ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo).
- StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5).
- GET {id}/changelogs — policy KeHoachKyKet.Read.
- POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only).
- approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE.
- KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo
gotcha #82 (class [Authorize] tran + changelogs khong policy).
FE (x2 app, SHA256 byte-identical 8/8):
- components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx;
KhkkWorkflowPanel doi tu pages/ sang components/.
- Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay
DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta
"x/y cap da duyet · N NV tham gia" thay list phang 1 cot.
- Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo
buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su
thay doi. Chua chon phieu -> hop dashed y PE (QD owner).
4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH
khong phai chap-nhan VO):
- M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen
KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc —
display-layer khoa authz-layer dang mo, mat con lai cua #82.
- M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy
trang PE/GD3.
- B1 stage2Content di CAP voi stage2Count qua conditional-spread:
khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8.
- R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao
"Cay tai 200 phieu moi nhat".
Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app ·
mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) ·
KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang
nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor
thanh useEffect se tai-hien va-5).
Con no, khai thang:
- E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px)
THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff
bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang.
- Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other
(full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao
new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do
(git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san".
- 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev
0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu
KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe ·
khoi file tu-an khi rong.
- P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan"
do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu
workflow Duyet NCC prod").
Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
371 lines
17 KiB
TypeScript
371 lines
17 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ý 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 HĐ
|
|
</Button>
|
|
</span>
|
|
)}
|
|
<Button variant="outline" onClick={() => navigate(backToList)}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Danh sách
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* Panel duyệt đứng TRƯỚC nội dung — người duyệt thấy việc-phải-làm trước (mirror thứ
|
|
tự của màn Duyệt NCC). Nút Xóa phiếu nay nằm trong panel này. */}
|
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} onDeleted={() => navigate(backToList)} />
|
|
|
|
<KhkkDetailContent plan={k} readOnly={readOnly} onChanged={invalidate} />
|
|
|
|
{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 “{plan.maKeHoach ?? 'chưa cấp mã'}”</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ị HĐ lấy đúng <strong>giá duyệt đã chốt</strong> của dòng — không lấy giá đề xuất.</li>
|
|
<li>HĐ mới ở trạng thái <strong>Nháp</strong> và đi theo quy trình duyệt bạn chọn dưới đây.</li>
|
|
<li>Dòng đã đưa vào HĐ thì <strong>không đưa lần hai</strong> — cần sửa thì xử lý ở 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 có 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">
|
|
Đã có HĐ
|
|
</a>
|
|
)}
|
|
{!done && noPrice && <span className="shrink-0 text-[11px]">Chưa có 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 có 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
|
|
và đượ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 và hạng mục của dòng.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
)
|
|
}
|