1. Ho thu: GO `PipelineTreePanel` khoi `/inbox` — anh: "cho hop thu vut panel nay vao lam j, tap trung vao KHKK thoi". Ho thu = hang-doi-viec-cua-toi, cay toan trinh = ban-do-toan-he => sai the loai. Cho trong nay tra ve dung vai: cho quy trinh duyet cua HD dang chon. 🔸 HAI trang GD3/GD4 con lai (`MyContractsPage` · `HardCopiesPage`) GIU NGUYEN — anh chi dich danh Ho thu, khong noi go het; khong tu suy rong. (Lineage: panel vao day @S168 GD3-D theo owner slot (63) + "mo rong = ca 3 trang"; nay owner thu lai DUNG trang nay.) 2. Panel KHKK: BO nut "+ Them moi". Man nay KHONG co duong "tao tu so 0" — ke hoach LUON sinh tu mot phieu Duyet NCC, ma cac phieu du dieu kien da nam nguyen o tang tren => nut chi xoa form chu khong tao them duoc gi. Khac CO CHU DICH voi `PeListPanel`. 3. Tang duoi CHI giu ke hoach DANG XU LY — an phieu `DaDuyet` (anh: "da duyet bo di luon"). 🔴 KHAI GIOI-HAN: loc o FE vi endpoint chi nhan `phase` DON, khong co tham so "loai tru" => (a) KHONG dung `total` cua server lam so hien thi (da gom phieu DaDuyet) — dem tu mang da loc; (b) cua-so 30 phieu moi nhat co the TOAN DaDuyet => tang duoi rong du con phieu nhap cu hon. Chap nhan o UAT; muon chuan thi BE them tham so loai-tru. Doi luon dong chu "x/total" -> "Da an ke hoach Da duyet": ti so cu doc thanh "dang cat bot" trong khi that ra la "dang loc bo". 4. Nhom duyet + Quy trinh duyet GAN CUNG theo leaf menu — anh: "gan cung luon theo tung nhom ko cho User dieu chinh cho nay luon". - `approvalGroup` khong con la state, DAN XUAT thang tu `?group=n`. Bo luon khoi dong-bo `lastPresetGroup` + ham `firstFreeGroup()` (chung chi ton tai de tu nhay nhom = dung hanh-vi vua bi cam). - Quy trinh: match theo ma tu NGUON BE, khong suy tu man hinh — `DbInitializer.cs:609` `var code = $"KHKK-N{n}"`. Khong khop => CHAN tao + noi ro thieu ma nao, TUYET DOI khong tu roi ve quy trinh nhom khac (gan nham = phieu di sai duong duyet). - UI: hien gia-tri co dinh, CO Y khong dung `<Select disabled>` (o xam van trong nhu "bam duoc nhung dang khoa" va van trong tab-order). 5. Panel giua: GO Section 5 "Y kien cap duyet" (anh: "bo luon cai nay ra luon"). So-do Buoc/Cap + chu ky da song o Panel 3 => de ca hai la ke mot chuyen hai lan tren cung man. DTO giu nguyen (`levelOpinions`/`workflowSteps` panel 3 van dung) — chi bo cho VE. Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
629 lines
30 KiB
TypeScript
629 lines
30 KiB
TypeScript
// [S171 KHKK 3-panel] PANEL 2 (giữa) — nội-dung phiếu "Kế hoạch ký kết HĐ" (GĐ2).
|
||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||
//
|
||
// 🔴 Khối ý-kiến cấp duyệt (Section 5) là bản thứ 3 của cùng một hình (PE
|
||
// `PeDetailTabs.tsx:705-774` · HĐ `components/contracts/WorkflowHistoryPanel.tsx`).
|
||
// Gộp được KHI VÀ CHỈ KHI BE 3 module thống-nhất shape `status` precompute — trước đó
|
||
// gộp chỉ đẻ ra `if (module === …)`.
|
||
//
|
||
// Bố-cục mirror Duyệt NCC: dải 4 giai-đoạn trên cùng, rồi các Section ĐÁNH SỐ nối nhau
|
||
// bằng `divide-y`, ý-kiến cấp duyệt nằm DƯỚI CÙNG (Ảnh 3 khoanh đỏ).
|
||
// 1. Thông tin kế hoạch 4. File đính kèm
|
||
// 2. Giá đề xuất theo NCC 5. Ý kiến cấp duyệt
|
||
// 3. Căn cứ hồ sơ (b.8-9)
|
||
// 🔸 KHÁC PE ở NỘI DUNG (miễn-trừ P2-3, có lý-do nghiệp-vụ): GĐ2 không có báo-giá / so-sánh
|
||
// NCC (đã chốt ở GĐ1) mà có căn-cứ hồ-sơ TVGS. Giống nhau ở KHUNG, không ở chữ.
|
||
//
|
||
// [P2-7] `readOnly` là prop TỪ HOST — host quyết `phiếu ≠ Nháp ∨ user ≠ người soạn`.
|
||
// Không dựng workspace riêng cho việc này.
|
||
// [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback
|
||
// V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu.
|
||
import { useState, type ReactNode } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import {
|
||
ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||
} from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
|
||
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 { Textarea } from '@/components/ui/Textarea'
|
||
import { api } from '@/lib/api'
|
||
import { getErrorMessage } from '@/lib/apiError'
|
||
import { cn } from '@/lib/cn'
|
||
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 DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||
type UpsertKhkkDossierItemInput,
|
||
} from '@/types/khkk'
|
||
|
||
function formatVnd(n: number | null | undefined): string {
|
||
if (n === null || n === undefined) return '—'
|
||
return n.toLocaleString('vi-VN') + ' đ'
|
||
}
|
||
|
||
function formatDateTime(iso: string): string {
|
||
return new Date(iso).toLocaleString('vi-VN')
|
||
}
|
||
|
||
function formatSize(b: number): string {
|
||
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
|
||
}
|
||
|
||
/** Khung Section ĐÁNH SỐ — mirror khuôn Section của màn Duyệt NCC (đánh số + divide-y). */
|
||
function Section({
|
||
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="px-5 py-4">
|
||
<header className="mb-3 flex items-center gap-2">
|
||
<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)} style={{ ['--accent' as string]: accent }}>
|
||
{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>
|
||
{children}
|
||
</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 KhkkDetailContent({
|
||
plan,
|
||
readOnly,
|
||
onChanged,
|
||
}: {
|
||
plan: KhkkDetailDto
|
||
/** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */
|
||
readOnly: boolean
|
||
onChanged?: () => void
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const id = plan.id
|
||
const [editHeader, setEditHeader] = useState(false)
|
||
const [ghiChu, setGhiChu] = useState('')
|
||
const [hoSoLink, setHoSoLink] = useState('')
|
||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||
|
||
const phase = plan.phase as KhkkPhaseValue
|
||
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||
// Sửa được = phiếu còn ở Nháp/Trả lại VÀ host không khoá (readOnly).
|
||
const isDraft = draftPhase && !readOnly
|
||
|
||
const invalidate = () => {
|
||
qc.invalidateQueries({ queryKey: ['khkk-detail', id] })
|
||
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
||
onChanged?.()
|
||
}
|
||
|
||
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)),
|
||
})
|
||
|
||
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
|
||
const catalogOptions = useQuery({
|
||
queryKey: ['contract-catalog', plan.approvalGroup],
|
||
queryFn: async () =>
|
||
(await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
|
||
params: { approvalGroup: plan.approvalGroup },
|
||
})).data,
|
||
enabled: isDraft && plan.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ứ')
|
||
const payload = {
|
||
...body,
|
||
contractSigningPlanId: id,
|
||
name: body.name.trim(),
|
||
tvgsName: body.tvgsName?.trim() || null,
|
||
note: body.note?.trim() || null,
|
||
}
|
||
// [F-4 S161] POST ép Id=null = LUÔN insert ⇒ "Sửa" phải đi PUT, không thì đẻ bản sao im lặng.
|
||
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)),
|
||
})
|
||
|
||
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 */ }
|
||
}
|
||
}
|
||
|
||
const startEditHeader = () => {
|
||
setGhiChu(plan.ghiChu ?? '')
|
||
setHoSoLink(plan.hoSoLink ?? '')
|
||
setEditHeader(true)
|
||
}
|
||
|
||
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
|
||
const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment)
|
||
|
||
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
|
||
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
|
||
// là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
|
||
// `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây.
|
||
|
||
return (
|
||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||
{/* Dải 4 giai-đoạn — GĐ2 đang sáng. Cùng component với màn Duyệt NCC (không nhân bản). */}
|
||
<PePipelineStrip current={2} />
|
||
|
||
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 px-5 py-3">
|
||
<span className="font-mono text-sm font-semibold text-brand-800">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
|
||
<span className="min-w-0 flex-1 truncate text-sm text-slate-700">{plan.peTenGoiThau ?? '—'}</span>
|
||
{plan.approvalGroup != null && (
|
||
<span
|
||
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2 py-0.5 text-[11px] font-semibold text-violet-800"
|
||
title={KHKK_APPROVAL_GROUP_LABELS[plan.approvalGroup] ?? `Nhóm ${plan.approvalGroup}`}
|
||
>
|
||
N{plan.approvalGroup}
|
||
</span>
|
||
)}
|
||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', KHKK_PHASE_BADGE[phase])}>
|
||
{KHKK_PHASE_LABELS[phase]}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="divide-y divide-slate-200">
|
||
{/* ─────────────── 1 ─────────────── */}
|
||
<Section
|
||
title="1. 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={plan.maKeHoach ?? '—'} mono />
|
||
<Field label="Phiếu Duyệt NCC nguồn">
|
||
<span className="font-mono text-sm">{plan.peMaPhieu ?? '—'}</span>
|
||
</Field>
|
||
<Field label="Dự án" value={plan.projectName ?? '—'} />
|
||
<Field label="Phòng ban" value={plan.departmentName ?? '—'} />
|
||
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
|
||
<Field label="Quy trình">
|
||
{plan.workflowCode ? (
|
||
<span>
|
||
<span className="font-mono">{plan.workflowCode}</span>
|
||
{plan.workflowVersion != null && <span className="font-mono"> v{plan.workflowVersion}</span>}
|
||
{plan.workflowName ? ` · ${plan.workflowName}` : ''}
|
||
</span>
|
||
) : (
|
||
<span className="text-slate-300">— Chưa chọn —</span>
|
||
)}
|
||
</Field>
|
||
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
|
||
<Field label="Hạn xử lý (tham khảo)" value={plan.slaDeadline ? formatDateTime(plan.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>
|
||
{plan.hoSoLink ? <span className="break-all font-normal text-slate-700">{plan.hoSoLink}</span> : <span className="text-slate-300">—</span>}
|
||
</Field>
|
||
<Field label="Ghi chú" full>
|
||
{plan.ghiChu ? <span className="whitespace-pre-wrap font-normal text-slate-700">{plan.ghiChu}</span> : <span className="text-slate-300">—</span>}
|
||
</Field>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
|
||
{/* ─────────────── 2 ─────────────── */}
|
||
<Section
|
||
title={`2. Giá đề xuất theo NCC trúng thầu (${plan.lines.length} dòng)`}
|
||
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)"
|
||
actions={
|
||
plan.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
|
||
}
|
||
>
|
||
{plan.lines.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<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>
|
||
<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</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>
|
||
{plan.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 ?? '—'}
|
||
{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">
|
||
✓ HĐ
|
||
</a>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-xs text-slate-700">
|
||
{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>
|
||
{/* Hạng mục ĐÃ LƯU mà không có trong options ⇒ chèn option đại diện,
|
||
không thì dòng đã gán TRÔNG như chưa gá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{plan.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>
|
||
)}
|
||
<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>
|
||
</Section>
|
||
|
||
{/* ─────────────── 3 ─────────────── */}
|
||
<Section
|
||
title="3. 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={plan.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
|
||
}
|
||
>
|
||
{plan.dossierItems.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<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>
|
||
{plan.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>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
|
||
{/* ─────────────── 4 ─────────────── */}
|
||
<Section
|
||
title="4. 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={planAttachments.length}
|
||
actions={
|
||
readOnly ? undefined : (
|
||
<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>
|
||
)
|
||
}
|
||
>
|
||
{planAttachments.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">Chưa có file đính kèm.</div>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{planAttachments.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>
|
||
{!readOnly && (
|
||
<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>
|
||
)}
|
||
</Section>
|
||
</div>
|
||
|
||
{/* 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>
|
||
</div>
|
||
)
|
||
}
|