All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m27s
- Dry-run K8 bat TRUOC gio chay: auto-gen line moi CatalogEntryId=null + comment hen "UpdateContractSigningPlanLineCommand" (K4b) nhung K4b that ship ?group= => 0 impl/0 route/0 FE; submit-guard va-5 chan null => MOI phieu KHKK tao sau K2 ket Nhap vinh vien (dung cho cicd-K2 khai "chan-ly-rong")
- BE: UpdateContractSigningPlanLineCommand(PlanId,LineId,CatalogEntryId) — EnsureDraftEditable + rao ContractId!=null 409 (gate F-6) + fail-fast entry song/IsActive/dung-nhom 409 + denorm TenHangMuc + changelog EntityType.Line EntityId=line.Id + ten NCC vao Summary (gate F-1)
- API: PUT /contract-signing-plans/{id}/lines/{lineId} policy KeHoachKyKet.Update + UpdateSigningPlanLineBody
- FE x2 app KhkkDetailPage (SHA-pair 0d6147d0): select gan tai bang (Nhap/TraLai, khoa dong da bac cau HD) + option-fallback giu hang-muc-da-luu khi vang options (gate F-2, 3 ca loading/error/ngung-hieu-luc) + dong loi query + amber nhom-0-hang-muc (F-3) + aria-label per-dong (F-8)
- Test +5 Fact: duong gan->submit-QUA guard that · khac-nhom 409 · phase 409 · IsActive=false 409 · gan-DE doi ca Id lan snapshot (gate F-4)
- Gate PASS-WITH-FLAGS-8/0-blocker — disposition tung dong trong sub-reviewer-gate-lineeditor.md; F-5/F-7 ghi no cung goi RowVersion/unique voi no K7 race-2-request
- scripts/backup-sql.ps1 default D:\ -> C:\ (VPS khong co o D — do that B0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1094 lines
47 KiB
TypeScript
1094 lines
47 KiB
TypeScript
// [W2 KHKK — S161 2026-07-29] Chi tiết "Kế hoạch ký kết HĐ" (GĐ2).
|
|
// Khuôn `pages/office/ProposalDetailPage.tsx` (SectionCard + Field idiom PURO).
|
|
// File MIRROR SHA256 identical với fe-admin counterpart.
|
|
//
|
|
// 4 section: (1) Thông tin + ghi chú/link hồ sơ sửa được khi nháp · (2) Dòng giá
|
|
// 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).
|
|
// [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'
|
|
import {
|
|
ArrowLeft, ClipboardList, FileCheck, FileSignature, Paperclip, Pencil, Plus, Save, Trash2,
|
|
Upload, Users,
|
|
} 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 { Textarea } from '@/components/ui/Textarea'
|
|
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 {
|
|
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
|
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
|
|
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
|
KhkkAttachmentPurpose, KhkkPhase,
|
|
type CreateContractFromKhkkInput, type CreateContractFromKhkkResult,
|
|
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
|
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
|
|
type UpsertKhkkDossierItemInput,
|
|
} from '@/types/khkk'
|
|
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
|
|
|
|
function formatVnd(n: number | null): string {
|
|
if (n === null || n === undefined) return '—'
|
|
return n.toLocaleString('vi-VN') + ' đ'
|
|
}
|
|
|
|
function formatDateTime(iso: string): string {
|
|
const d = new Date(iso)
|
|
return d.toLocaleString('vi-VN')
|
|
}
|
|
|
|
function formatSize(b: number): string {
|
|
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
|
|
}
|
|
|
|
// Section card — .card-accent shell + .icon-chip header (Hồ sơ-NS idiom).
|
|
function SectionCard({
|
|
title, icon, accent, head, chipBg, chipFg, count, actions, children,
|
|
}: {
|
|
title: string
|
|
icon: ReactNode
|
|
accent: string
|
|
head: string
|
|
chipBg: string
|
|
chipFg: string
|
|
count?: number
|
|
actions?: ReactNode
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<section className="card-accent" style={{ ['--accent' as string]: accent }}>
|
|
<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]: chipBg, ['--chip-fg' as string]: chipFg }}
|
|
aria-hidden
|
|
>
|
|
{icon}
|
|
</span>
|
|
<h3 className={cn('text-sm font-semibold tracking-tight', head)}>{title}</h3>
|
|
{count != null && (
|
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
|
{count}
|
|
</span>
|
|
)}
|
|
{actions && <span className="ml-auto">{actions}</span>}
|
|
</header>
|
|
<div className="p-5">{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function Field({
|
|
label, value, mono, full, children,
|
|
}: {
|
|
label: string
|
|
value?: ReactNode
|
|
mono?: boolean
|
|
full?: boolean
|
|
children?: ReactNode
|
|
}) {
|
|
const empty = value == null || value === ''
|
|
return (
|
|
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
|
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
|
|
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
|
|
{children ?? (empty ? '—' : value)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const EMPTY_ITEM: UpsertKhkkDossierItemInput = {
|
|
kind: DossierItemKind.MauVatLieu,
|
|
name: '',
|
|
status: DossierItemStatus.ChuaNop,
|
|
tvgsName: '',
|
|
note: '',
|
|
}
|
|
|
|
export function KhkkDetailPage() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const qc = useQueryClient()
|
|
|
|
const [editHeader, setEditHeader] = useState(false)
|
|
const [ghiChu, setGhiChu] = useState('')
|
|
const [hoSoLink, setHoSoLink] = useState('')
|
|
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
|
const [bridgeOpen, setBridgeOpen] = useState(false)
|
|
|
|
// [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.
|
|
// Chữ ký hook là `can(menuKey, action)` (`hooks/usePermission.ts:17`) — không có dạng
|
|
// `usePermission('X','Y')`.
|
|
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'] })
|
|
}
|
|
|
|
const saveHeader = useMutation({
|
|
mutationFn: async () => {
|
|
await api.put(`/contract-signing-plans/${id}`, {
|
|
ghiChu: ghiChu.trim() || null,
|
|
hoSoLink: hoSoLink.trim() || null,
|
|
})
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Đã lưu')
|
|
setEditHeader(false)
|
|
invalidate()
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
// [S168 K8-blocker — Lines-editor] Gán hạng mục SP-002 cho dòng: options lọc ĐÚNG nhóm
|
|
// duyệt của phiếu (BE fail-fast tầng-gán + submit-guard chốt cuối). Chỉ tải khi phiếu
|
|
// còn sửa được (Nháp/Trả lại) — tránh 1 request thừa trên phiếu đã trình/duyệt.
|
|
const planDraftEditable =
|
|
plan.data?.phase === KhkkPhase.DangSoanThao || plan.data?.phase === KhkkPhase.TraLai
|
|
const catalogOptions = useQuery({
|
|
queryKey: ['contract-catalog', plan.data?.approvalGroup],
|
|
queryFn: async () =>
|
|
(
|
|
await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
|
|
params: { approvalGroup: plan.data!.approvalGroup },
|
|
})
|
|
).data,
|
|
enabled: planDraftEditable && plan.data?.approvalGroup != null,
|
|
})
|
|
const assignLine = useMutation({
|
|
mutationFn: async (p: { lineId: string; catalogEntryId: string }) =>
|
|
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }),
|
|
onSuccess: () => {
|
|
toast.success('Đã gán hạng mục')
|
|
invalidate()
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const upsertItem = useMutation({
|
|
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
|
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
|
// [F-3 S161] BE verify id-path == cmd.ContractSigningPlanId. [F-4] POST ép Id=null
|
|
// = LUÔN insert ⇒ bấm "Sửa" phải đi PUT, không thì đẻ bản sao im lặng.
|
|
const payload = {
|
|
...body,
|
|
contractSigningPlanId: id,
|
|
name: body.name.trim(),
|
|
tvgsName: body.tvgsName?.trim() || null,
|
|
note: body.note?.trim() || null,
|
|
}
|
|
if (body.id) await api.put(`/contract-signing-plans/${id}/dossier-items/${body.id}`, payload)
|
|
else await api.post(`/contract-signing-plans/${id}/dossier-items`, payload)
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Đã lưu căn cứ')
|
|
setItemForm(null)
|
|
invalidate()
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const deleteItem = useMutation({
|
|
mutationFn: async (itemId: string) =>
|
|
api.delete(`/contract-signing-plans/${id}/dossier-items/${itemId}`),
|
|
onSuccess: () => {
|
|
toast.success('Đã xóa căn cứ')
|
|
invalidate()
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const upload = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
|
|
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
})
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Đã tải lên')
|
|
invalidate()
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const deleteAttachment = useMutation({
|
|
mutationFn: async (attId: string) => api.delete(`/contract-signing-plans/${id}/attachments/${attId}`),
|
|
onSuccess: () => {
|
|
toast.success('Đã xóa file')
|
|
invalidate()
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const deletePlan = useMutation({
|
|
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
|
|
onSuccess: () => {
|
|
toast.success('Đã xóa kế hoạch')
|
|
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
|
navigate('/khkk/list')
|
|
},
|
|
onError: (e) => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
async function download(att: KhkkAttachmentDto) {
|
|
try {
|
|
const res = await api.get(`/contract-signing-plans/${id}/attachments/${att.id}/download`, {
|
|
responseType: 'blob',
|
|
})
|
|
const url = window.URL.createObjectURL(res.data as Blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = att.fileName
|
|
a.click()
|
|
window.URL.revokeObjectURL(url)
|
|
} catch (e) {
|
|
toast.error(getErrorMessage(e))
|
|
}
|
|
}
|
|
|
|
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const files = Array.from(e.target.files ?? [])
|
|
e.target.value = ''
|
|
for (const f of files) {
|
|
try { await upload.mutateAsync(f) } catch { /* lỗi đã toast ở onError */ }
|
|
}
|
|
}
|
|
|
|
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('/khkk/list')}>
|
|
<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
|
|
const isDraft = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
|
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
|
|
|
|
// [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE
|
|
// (`§K7.1` guard 4 + 5). Chặn sớm ở FE để người dùng không bấm rồi mới ăn 409.
|
|
const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null)
|
|
// Nút chỉ có nghĩa khi phiếu ĐÃ DUYỆT (giá mới chốt) và người dùng đọc được phiếu.
|
|
const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan
|
|
// Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút: người dùng đang đứng trên phiếu, ẩn đi họ
|
|
// chỉ đoán "hệ thống không có chức năng". Khuôn nút-khoá-kèm-title `PeWorkflowPanel.tsx: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
|
|
|
|
const startEditHeader = () => {
|
|
setGhiChu(k.ghiChu ?? '')
|
|
setHoSoLink(k.hoSoLink ?? '')
|
|
setEditHeader(true)
|
|
}
|
|
|
|
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={
|
|
<>
|
|
{/* [K2 S164] Badge NHÓM DUYỆT — đứng TRƯỚC badge phase vì nhóm là thứ quyết
|
|
phiếu đi đường nào (8 quy trình khác nhau), phase chỉ là chỗ đang đứng.
|
|
`!= null` guard: phiếu nạp từ cache trước K2 chưa có field ⇒ đừng in
|
|
"Nundefined" (bài "vắng-mặt trông giống ổn"). */}
|
|
{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>
|
|
{/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `<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, người dùng mất luôn câu giải thích. */}
|
|
{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>
|
|
)}
|
|
{canDeletePlan && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (window.confirm('Xóa kế hoạch ký kết này?')) deletePlan.mutate()
|
|
}}
|
|
disabled={deletePlan.isPending}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Xóa
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Danh sách
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* [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"
|
|
icon={<FileCheck className="h-4 w-4" />}
|
|
accent="var(--color-brand-500)"
|
|
head="text-brand-800"
|
|
chipBg="var(--color-brand-50)"
|
|
chipFg="var(--color-brand-600)"
|
|
actions={
|
|
isDraft && !editHeader ? (
|
|
<Button variant="outline" size="sm" onClick={startEditHeader}>
|
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
|
Sửa
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
>
|
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
|
<Field label="Mã kế hoạch" value={k.maKeHoach ?? '—'} mono />
|
|
<Field label="Phiếu Duyệt NCC nguồn">
|
|
<span className="font-mono text-sm">{k.peMaPhieu ?? '—'}</span>
|
|
</Field>
|
|
<Field label="Dự án" value={k.projectName ?? '—'} />
|
|
<Field label="Phòng ban" value={k.departmentName ?? '—'} />
|
|
<Field label="Người soạn" value={k.drafterFullName ?? '—'} />
|
|
<Field label="Quy trình">
|
|
{k.workflowCode ? (
|
|
<span>
|
|
<span className="font-mono">{k.workflowCode}</span> - {k.workflowName}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-300">— Chưa chọn —</span>
|
|
)}
|
|
</Field>
|
|
<Field label="Ngày tạo" value={formatDateTime(k.createdAt)} />
|
|
<Field label="Hạn xử lý (tham khảo)" value={k.slaDeadline ? formatDateTime(k.slaDeadline) : '—'} />
|
|
</div>
|
|
|
|
{editHeader ? (
|
|
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
|
|
<div>
|
|
<Label htmlFor="hoSoLink">Link hồ sơ (NAS)</Label>
|
|
<Input
|
|
id="hoSoLink"
|
|
value={hoSoLink}
|
|
onChange={(e) => setHoSoLink(e.target.value)}
|
|
maxLength={1000}
|
|
placeholder="\\\\nas\\du-an\\..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="ghiChu">Ghi chú</Label>
|
|
<Textarea
|
|
id="ghiChu"
|
|
value={ghiChu}
|
|
onChange={(e) => setGhiChu(e.target.value)}
|
|
maxLength={2000}
|
|
rows={4}
|
|
/>
|
|
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>
|
|
Huỷ
|
|
</Button>
|
|
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
|
|
<Save className="mr-2 h-3.5 w-3.5" />
|
|
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
|
<Field label="Link hồ sơ" full>
|
|
{k.hoSoLink ? (
|
|
<span className="break-all font-normal text-slate-700">{k.hoSoLink}</span>
|
|
) : (
|
|
<span className="text-slate-300">—</span>
|
|
)}
|
|
</Field>
|
|
<Field label="Ghi chú" full>
|
|
{k.ghiChu ? (
|
|
<span className="whitespace-pre-wrap font-normal text-slate-700">{k.ghiChu}</span>
|
|
) : (
|
|
<span className="text-slate-300">—</span>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Section 2: Dòng giá per-NCC trúng thầu */}
|
|
<SectionCard
|
|
title="Giá đề xuất theo NCC trúng thầu"
|
|
icon={<Users className="h-4 w-4" />}
|
|
accent="var(--color-teal-500)"
|
|
head="text-teal-700"
|
|
chipBg="var(--color-teal-50)"
|
|
chipFg="var(--color-teal-700)"
|
|
count={k.lines.length}
|
|
actions={
|
|
// [K2 S164 — QĐ-9] `budgetFrozen` = cờ HIỂN THỊ do BE dẫn xuất từ phase
|
|
// (`DetailDto:166`). Số đã đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=`
|
|
// tại choke-point finalize — badge này chỉ nói cho người dùng biết điều đó.
|
|
k.budgetFrozen ? (
|
|
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
|
|
🔒 Giá đã chốt tại thời điểm duyệt
|
|
</span>
|
|
) : undefined
|
|
}
|
|
>
|
|
{k.lines.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
|
<tr>
|
|
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
|
{/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land
|
|
@S168 (mảnh hẹn-K4b bị rơi, dry-run K8 bắt): Nháp/Trả lại gán tại bảng,
|
|
dòng trống KHÔNG trình duyệt được (submit-guard BE). */}
|
|
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
|
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
|
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
|
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
|
|
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{k.lines.map((l) => (
|
|
<tr key={l.id} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-3 py-2 font-medium text-brand-800">
|
|
{l.supplierName ?? '—'}
|
|
{/* [K7 S167] Dòng đã bắc cầu — khuôn dấu "✓ HĐ" của danh sách PE
|
|
(`PurchaseEvaluationsListPage.tsx:599`). Thấy ngay ở bảng chứ không
|
|
phải mở dialog mới biết dòng nào đã xong. */}
|
|
{l.contractId && (
|
|
<a
|
|
href={`/contracts/${l.contractId}`}
|
|
className="ml-2 text-[10px] font-medium text-brand-600 hover:underline"
|
|
title="Dòng này đã được đưa vào hợp đồng — bấm để mở HĐ"
|
|
>
|
|
✓ HĐ
|
|
</a>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-slate-700">
|
|
{/* Dòng đã bắc cầu HĐ thì khoá (đổi hạng mục sau khi có HĐ = đổi lịch sử). */}
|
|
{isDraft && !l.contractId ? (
|
|
<>
|
|
<select
|
|
value={l.catalogEntryId ?? ''}
|
|
onChange={(e) =>
|
|
e.target.value &&
|
|
assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })
|
|
}
|
|
disabled={assignLine.isPending}
|
|
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
|
|
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
|
>
|
|
<option value="">— Chọn hạng mục —</option>
|
|
{/* [gate-lineeditor F-2] Hạng mục ĐÃ LƯU mà không có trong options
|
|
(đang tải / query lỗi / entry hết hiệu lực) ⇒ chèn option đại diện
|
|
từ dữ liệu đã lưu — không thì select rơi về placeholder và dòng
|
|
đã gán TRÔNG như chưa gán (vắng-mặt trông giống ổn). */}
|
|
{l.catalogEntryId &&
|
|
!(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
|
<option value={l.catalogEntryId}>
|
|
{l.tenHangMuc ?? '(hạng mục đã gán)'}
|
|
</option>
|
|
)}
|
|
{(catalogOptions.data ?? []).map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.code} — {c.tenVi}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{catalogOptions.isError && (
|
|
<div className="mt-1 text-[11px] text-red-600">
|
|
Không tải được danh mục hạng mục —{' '}
|
|
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>
|
|
thử lại
|
|
</button>
|
|
</div>
|
|
)}
|
|
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
|
|
<div className="mt-1 text-[11px] text-amber-700">
|
|
Nhóm duyệt N{k.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục.
|
|
</div>
|
|
)}
|
|
</>
|
|
) : l.tenHangMuc ? (
|
|
l.tenHangMuc
|
|
) : (
|
|
<span className="text-slate-300">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td>
|
|
<td className="px-3 py-2 text-xs text-slate-600">{l.note ?? '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
<div className="mt-3 text-xs text-muted-foreground">
|
|
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
|
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
|
</div>
|
|
</SectionCard>
|
|
|
|
{/* Section 3: Căn cứ hồ sơ b.8-9 */}
|
|
<SectionCard
|
|
title="Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
|
|
icon={<ClipboardList className="h-4 w-4" />}
|
|
accent="var(--color-violet-500)"
|
|
head="text-violet-700"
|
|
chipBg="var(--color-violet-50)"
|
|
chipFg="var(--color-violet-700)"
|
|
count={k.dossierItems.length}
|
|
actions={
|
|
isDraft ? (
|
|
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
|
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
|
Thêm căn cứ
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
>
|
|
{k.dossierItems.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
|
<tr>
|
|
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
|
|
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
|
|
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
|
|
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
|
|
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
|
|
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{k.dossierItems.map((d: KhkkDossierItemDto) => (
|
|
<tr key={d.id} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td>
|
|
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
|
DOSSIER_STATUS_BADGE[d.status],
|
|
)}
|
|
>
|
|
{DOSSIER_STATUS_LABELS[d.status]}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-slate-600">
|
|
{d.tvgsName ?? '—'}
|
|
{d.tvgsResultAt && (
|
|
<span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td>
|
|
{isDraft && (
|
|
<td className="px-3 py-2 text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setItemForm({
|
|
id: d.id,
|
|
kind: d.kind,
|
|
supplierId: d.supplierId,
|
|
name: d.name,
|
|
status: d.status,
|
|
tvgsName: d.tvgsName ?? '',
|
|
note: d.note ?? '',
|
|
})
|
|
}
|
|
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
|
|
title="Sửa"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id)
|
|
}}
|
|
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
|
|
title="Xóa"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Section 4: File đính kèm — mở mọi phase */}
|
|
<SectionCard
|
|
title="File đính kèm"
|
|
icon={<Paperclip className="h-4 w-4" />}
|
|
accent="var(--color-teal-500)"
|
|
head="text-teal-700"
|
|
chipBg="var(--color-teal-50)"
|
|
chipFg="var(--color-teal-700)"
|
|
count={k.attachments.length}
|
|
actions={
|
|
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
|
|
<Upload className="mr-2 h-3.5 w-3.5" />
|
|
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
|
|
<input type="file" multiple className="hidden" onChange={onPickFiles} />
|
|
</label>
|
|
}
|
|
>
|
|
{k.attachments.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">Chưa có file đính kèm.</div>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{k.attachments.map((a) => (
|
|
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">
|
|
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">
|
|
Tải xuống
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id)
|
|
}}
|
|
className="text-xs text-red-600 hover:underline"
|
|
>
|
|
Xóa
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Dialog thêm/sửa căn cứ */}
|
|
<Dialog
|
|
open={!!itemForm}
|
|
onClose={() => setItemForm(null)}
|
|
title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}
|
|
>
|
|
{itemForm && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label htmlFor="item-kind">Loại căn cứ</Label>
|
|
<select
|
|
id="item-kind"
|
|
value={itemForm.kind}
|
|
onChange={(e) => setItemForm({ ...itemForm, kind: Number(e.target.value) as DossierItemKindValue })}
|
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
|
>
|
|
{Object.entries(DOSSIER_KIND_LABELS).map(([v, label]) => (
|
|
<option key={v} value={v}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="item-name">
|
|
Tên căn cứ <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="item-name"
|
|
value={itemForm.name}
|
|
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
|
|
maxLength={300}
|
|
placeholder="vd. Mẫu thép D10 Hoà Phát"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="item-status">Trạng thái</Label>
|
|
<select
|
|
id="item-status"
|
|
value={itemForm.status}
|
|
onChange={(e) => setItemForm({ ...itemForm, status: Number(e.target.value) as DossierItemStatusValue })}
|
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
|
>
|
|
{Object.entries(DOSSIER_STATUS_LABELS).map(([v, label]) => (
|
|
<option key={v} value={v}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="item-tvgs">Tên TVGS xử lý</Label>
|
|
<Input
|
|
id="item-tvgs"
|
|
value={itemForm.tvgsName ?? ''}
|
|
onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })}
|
|
maxLength={200}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="item-note">Ghi chú</Label>
|
|
<Textarea
|
|
id="item-note"
|
|
value={itemForm.note ?? ''}
|
|
onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })}
|
|
maxLength={1000}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" onClick={() => setItemForm(null)}>
|
|
Huỷ
|
|
</Button>
|
|
<Button
|
|
onClick={() => upsertItem.mutate(itemForm)}
|
|
disabled={upsertItem.isPending || !itemForm.name.trim()}
|
|
>
|
|
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
|
|
{/* [K7 S167 — vá-16] Dialog bắc cầu. Render có điều kiện (không dùng prop `open`) để
|
|
mỗi lần mở là state mới — khuôn dialog Xóa-phiếu `PeWorkflowPanel.tsx:879`. */}
|
|
{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`
|
|
// (GET `/approval-workflows-v2?applicableType=3` → lọc theo cờ).
|
|
// [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ủa ContractCreatePage):
|
|
// 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Ờ (spec cụm-3 vá-2): `isUserSelectable` (admin ghim cho user chọn) ∧
|
|
// `isActive` (bản còn hiệu lực). Picker cũ `ContractCreatePage.tsx:341` chỉ lọc 1 cờ ⇒
|
|
// mời chọn được bản đã retire rồi ăn 409 từ guard BE. Ở đây lọc đủ ngay từ nguồn.
|
|
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 (`usePipelineStages.ts`) 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>
|
|
)
|
|
}
|