All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 7m2s
Anh giao qua anh prod man duyet KHKK, 2 diem khoanh do:
(1) "Bo 1 dong di nhe" -> go hau to "(N dong)" khoi title Section 2.
(2) "Hien thi stt va ma hang muc (Du an) - ma hang muc chi tiet"
-> bang 3->4 cot (+STT cot rieng, anh chot), cot HANG MUC them dong
ma "<ma du an> - <ma chi tiet>" nam TREN ten.
Mig 72 AddMaHangMucToContractSigningPlanLines (anh chot DENORM co migration):
- +cot MaHangMuc nvarchar(20) = BANG nguon ContractCatalogEntry.Code.
- backfill Sql() idempotent (WHERE MaHangMuc IS NULL), COY KHONG loc IsDeleted:
hang muc xoa mem SAU ngay lap phieu thi dong phieu van phai giu duoc ma —
do chinh la ly do cot nay la denorm. Loc di la bo trong dung dong can cuu nhat.
- ten bang doi chieu voi ToTable() cua CA HAI configuration truoc khi viet
(Mig 60 tung backfill soi NHAM BANG, chay sach, phai de Mig 61 va — gotcha #78/#79).
PeWorkItemCode KHONG denorm — noi join WorkItems san co o Features.cs:929, 0 cot moi.
Chup lai vao KHKK la chup mot thu von da la con tro loose-Guid qua PE (Mig 49).
3 write-site denorm sua DU: :527 moi null · :695 gan lai · :805 tao moi.
Sot 1 cho = de dong co ten ma khong co ma, chi lo ra o luong it ai bam.
FE 2 app SHA IDENTICAL (verify identical @HEAD TRUOC khi copy de).
Build BE 0 warning 0 error · 2 app build sach · 644/644 test pass.
Lint 42 van de = NO CO SAN, 0 cai o 2 file luot nay dung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
979 lines
55 KiB
TypeScript
979 lines
55 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, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Trash2, Upload, Users,
|
||
} from 'lucide-react'
|
||
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
||
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
||
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
||
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, KhkkTransitionAction, type KhkkTransitionInput,
|
||
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') + ' đ'
|
||
}
|
||
|
||
// [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng
|
||
// màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ.
|
||
// Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu.
|
||
const DASH = <span className="text-slate-300">—</span>
|
||
|
||
/** Chuỗi có thể rỗng → em-dash mờ. */
|
||
function orDash(v: string | null | undefined): ReactNode {
|
||
return v == null || v === '' ? DASH : v
|
||
}
|
||
|
||
/** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */
|
||
function moneyOrDash(n: number | null | undefined): ReactNode {
|
||
return n == null ? DASH : formatVnd(n)
|
||
}
|
||
|
||
/**
|
||
* [S177 — anh: "hiển thị stt và mã hạng mục (Dự án) - mã hạng mục chi tiết"]
|
||
* Ghép `<mã hạng mục dự án> - <mã hạng mục chi tiết>` cho dòng bảng hạng mục.
|
||
* 🔴 LỌC rồi mới nối, KHÔNG nối rồi mới cắt: thiếu một vế mà cứ chèn " - " thì ra
|
||
* "DAO_MONG - " hoặc " - A1-01" — dấu nối mồ côi trông như dữ liệu hỏng chứ không như
|
||
* thiếu dữ liệu. Cả hai vế rỗng ⇒ trả null để chỗ gọi BỎ HẲN dòng mã, không vẽ ô trống.
|
||
*/
|
||
function joinMaHangMuc(maDuAn: string | null, maChiTiet: string | null): string | null {
|
||
const parts = [maDuAn, maChiTiet].filter((s): s is string => s != null && s !== '')
|
||
return parts.length === 0 ? null : parts.join(' - ')
|
||
}
|
||
|
||
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>
|
||
{/* [S175 · C3] VALUE về `text-slate-900`, LABEL giữ `text-brand-600`. Trước đây MỌI
|
||
value đều tô brand ⇒ màu thương-hiệu mất hết sức nặng: khi cả trang đều là màu nhấn
|
||
thì không còn gì là nhấn, và không chừa được màu cho thứ THẬT SỰ cần nổi (NCC trúng
|
||
thầu, cảnh báo vượt ngân sách). PE giữ slate cho value đúng vì lý do này. */}
|
||
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-slate-900', 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,
|
||
onDeleted,
|
||
}: {
|
||
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
|
||
/** [S175] Gọi sau khi XÓA phiếu ở thanh nút đáy — host bỏ chọn / quay về danh sách. */
|
||
onDeleted?: () => void
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const id = plan.id
|
||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
||
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
||
// [S175] NCC đang mở form "thêm hạng mục" (null = không mở form nào).
|
||
const [addFor, setAddFor] = useState<{ supplierId: string; supplierName: string | null } | null>(null)
|
||
const [addCatalogId, setAddCatalogId] = useState('')
|
||
const [addAmount, setAddAmount] = useState('')
|
||
const [addNote, setAddNote] = useState('')
|
||
|
||
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?.()
|
||
}
|
||
|
||
|
||
// 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)),
|
||
})
|
||
|
||
// [S175 — anh chốt "nhiều hạng mục → giá và ghi chú"] 3 mutation cho DÒNG hạng mục.
|
||
// 🔴 Body null-safe: chỉ gửi field ĐANG sửa; field không gửi thì BE giữ nguyên
|
||
// (`UpdateContractSigningPlanLineCommand` — khuôn bug-class #73/S42).
|
||
const saveLineField = useMutation({
|
||
mutationFn: async (p: { lineId: string; proposedAmount?: number; note?: string }) =>
|
||
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
|
||
proposedAmount: p.proposedAmount,
|
||
note: p.note,
|
||
}),
|
||
onSuccess: () => { toast.success('Đã lưu dòng'); invalidate() },
|
||
onError: (e) => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const addLine = useMutation({
|
||
mutationFn: async (p: { supplierId: string; catalogEntryId: string; proposedAmount: number; note?: string }) =>
|
||
api.post(`/contract-signing-plans/${id}/lines`, p),
|
||
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
|
||
onError: (e) => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const deleteLine = useMutation({
|
||
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
|
||
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
|
||
onError: (e) => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
// [S175 — anh: "phía dưới thêm các nút thao tác"] 2 mutation cho THANH NÚT ĐÁY.
|
||
// 🔴 CỐ Ý chỉ ôm nhánh **Submit** — nhánh Approve/Return có máy phức tạp (ô-tích
|
||
// level-finalize + upload file đính kèm SAU khi transition thành công) và nó ở LẠI
|
||
// `KhkkWorkflowPanel`. Nhân bản nhánh đó sang đây là đẻ sự-thật thứ hai cho đường DUYỆT;
|
||
// nhánh Submit thì chỉ có đúng `action` + `comment: null` nên bê là an toàn.
|
||
const submitPlan = useMutation({
|
||
mutationFn: async () => {
|
||
const body: KhkkTransitionInput = { action: KhkkTransitionAction.Submit, comment: null }
|
||
return api.post(`/contract-signing-plans/${id}/transitions`, body)
|
||
},
|
||
onSuccess: () => { toast.success('Đã gửi duyệt'); invalidate(); onChanged?.() },
|
||
onError: (e) => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const deletePlan = useMutation({
|
||
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
|
||
onSuccess: () => { toast.success('Đã xóa phiếu'); onDeleted?.() },
|
||
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)),
|
||
})
|
||
|
||
// [S175 · O4] +`dossierItemId`: gắn file vào ĐÚNG một căn cứ hồ sơ (shopdrawing, mẫu vật
|
||
// liệu…) thay vì đổ chung một rổ. 🔴 0 BE MỚI — rail `dossierItemId` đã đủ 6 tầng từ Mig 69
|
||
// (`KhkkAttachmentDto:241` ⟵ `POST /attachments` multipart `dossierItemId?`); trước đây FE
|
||
// đơn giản là KHÔNG BAO GIỜ gửi trường đó lên, nên mọi file rơi về mức phiếu.
|
||
const upload = useMutation({
|
||
mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => {
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
|
||
if (dossierItemId) fd.append('dossierItemId', dossierItemId)
|
||
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>, dossierItemId?: string) {
|
||
const files = Array.from(e.target.files ?? [])
|
||
e.target.value = ''
|
||
for (const f of files) {
|
||
try { await upload.mutateAsync({ file: f, dossierItemId }) } catch { /* lỗi đã toast ở onError */ }
|
||
}
|
||
}
|
||
|
||
|
||
// 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.
|
||
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
|
||
// chỉ còn file mức PHIẾU. 🔴 Không chia thì file gắn căn cứ hiện ở CẢ HAI chỗ — người dùng
|
||
// đếm 2 lần rồi tưởng đã tải nhầm lên hai bận.
|
||
const dossierAttachments = new Map<string, KhkkAttachmentDto[]>()
|
||
const planAttachments: KhkkAttachmentDto[] = []
|
||
for (const a of plan.attachments) {
|
||
if (a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) continue
|
||
if (a.dossierItemId) {
|
||
const bucket = dossierAttachments.get(a.dossierItemId)
|
||
if (bucket) bucket.push(a)
|
||
else dossierAttachments.set(a.dossierItemId, [a])
|
||
} else {
|
||
planAttachments.push(a)
|
||
}
|
||
}
|
||
|
||
// [S175 · anh chốt] GỘP mục 3 (Căn cứ hồ sơ) + mục 4 (File đính kèm) thành MỘT khối
|
||
// "File đính kèm" khuôn Duyệt NCC (`PeDetailTabs.tsx:3382-3418`): 1 danh sách PHẲNG,
|
||
// mỗi dòng = 1 file có badge cho biết nó thuộc căn cứ nào.
|
||
// 🔴 CHỈ gộp phần HIỂN THỊ — máy căn cứ hồ sơ GIỮ NGUYÊN (entity `KhkkDossierItem`,
|
||
// 3 endpoint POST/PUT/DELETE dossier-items, changelog). Xoá máy đi để "cho gọn giao diện"
|
||
// là mất `kind`/`status`/TVGS — những thứ BE đang ghi và người duyệt đang đọc.
|
||
// Căn cứ ĐÃ KHAI mà CHƯA có file vẫn phải hiện 1 dòng, nếu không thì khai xong nó biến mất
|
||
// và người dùng tưởng bấm hụt.
|
||
type MergedRow =
|
||
| { rk: 'file'; att: KhkkAttachmentDto; item: KhkkDossierItemDto | null }
|
||
| { rk: 'empty'; item: KhkkDossierItemDto }
|
||
const mergedRows: MergedRow[] = []
|
||
for (const d of plan.dossierItems) {
|
||
const files = dossierAttachments.get(d.id) ?? []
|
||
if (files.length === 0) mergedRows.push({ rk: 'empty', item: d })
|
||
else for (const a of files) mergedRows.push({ rk: 'file', att: a, item: d })
|
||
}
|
||
for (const a of planAttachments) mergedRows.push({ rk: 'file', att: a, item: null })
|
||
|
||
// 🧊 [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 (
|
||
// [S175 — anh: "Viền xanh này trong thao tác" → rồi "ý tao bỏ cái viền xanh thôi, còn bộ
|
||
// khung table thuần vẫn nên giữ lại"] `.card-accent` (`index.css:112`) gói **HAI thứ**:
|
||
// ① KHUNG thẻ — bo góc 0.75rem + viền 1px `#e9eef4` + nền trắng + đổ bóng → anh GIỮ
|
||
// ② DẢI ACCENT DỌC 3px bên trái (`border-left`) → anh BỎ
|
||
// 🔴 Lượt trước lead gỡ CẢ CLASS ⇒ mất luôn ①, panel trôi không khung. Nay dựng lại ①
|
||
// bằng utility thuần, giữ đúng số đo gốc (`#e9eef4`, `rounded-xl`) để không lệch tông với
|
||
// các thẻ khác còn dùng `.card-accent`.
|
||
// 🔸 Bỏ luôn `hover` nhấc-thẻ của class gốc: hiệu-ứng đó thiết-kế cho KPI-card nhỏ, đặt
|
||
// lên panel nội-dung cao cả màn thì nhấp-nháy vô cớ mỗi lần rê chuột.
|
||
<div className="overflow-hidden rounded-xl border border-[#e9eef4] bg-white shadow-sm">
|
||
{/* 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>
|
||
{/* [S175 — anh: "Đưa lên trên phiếu — NCC"] Mã phiếu PE nguồn + NCC trúng thầu dời từ
|
||
mục 1 lên ĐÂY. Đây là 2 thứ người xem cần biết ngay khi mở phiếu, không phải cuộn
|
||
xuống bảng mới thấy. NCC lấy DUY-NHẤT từ `lines` (liên danh N NCC ⇒ liệt kê hết).
|
||
🔴 CÒN NỢ: "số tiền CCM và PRO duyệt" anh yêu cầu CHƯA đưa lên được — `KhkkDetailDto`
|
||
hiện 0 trường ngân sách (đo: `grep -c ccm khkk.ts` = 0); cần BE trả thêm. */}
|
||
{plan.peMaPhieu && (
|
||
<span
|
||
className="inline-flex items-center rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-600"
|
||
title="Phiếu Duyệt NCC nguồn"
|
||
>
|
||
{plan.peMaPhieu}
|
||
</span>
|
||
)}
|
||
{[...new Set(plan.lines.map(l => l.supplierName).filter(Boolean))].map(n => (
|
||
<span
|
||
key={n as string}
|
||
className="inline-flex max-w-[16rem] items-center truncate rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700"
|
||
title={`NCC trúng thầu: ${n}`}
|
||
>
|
||
{n}
|
||
</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 ─────────────── */}
|
||
{/* 🧊 [S175 — anh: "Bỏ hết đi"] Nút "Sửa" ĐÃ GỠ khỏi `actions`. Nó chỉ mở form 2 trường
|
||
**Link hồ sơ + Ghi chú** — mà chính 2 trường đó anh cũng bảo bỏ khỏi màn ở cùng lượt.
|
||
Giữ nút mở form sửa một thứ không còn hiển thị ở đâu là mâu thuẫn.
|
||
🔸 Máy KHÔNG mất: `hoSoLink`/`ghiChu` vẫn nằm trong DTO + BE
|
||
(`UpdateContractSigningPlanDraftCommand`), muốn bày lại chỉ là thêm chỗ vẽ. */}
|
||
<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)"
|
||
>
|
||
{/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô:
|
||
· "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý**
|
||
🔸 Quy trình vẫn xem được ở PANEL 3 (`KhkkWorkflowPanel` đầu khối) — gỡ ở đây
|
||
là bỏ chỗ vẽ TRÙNG, không mất thông tin.
|
||
· "Bỏ đi" → GỠ **Link hồ sơ · Ghi chú** khỏi ô xem (form Sửa vẫn còn, xem dưới)
|
||
· "Thêm hạng mục vào đây" → +ô **Hạng mục** ngay cạnh Dự án
|
||
· "Đưa lên trên phiếu" → **NCC** + mã phiếu PE dời lên dải header (xem `:361`) */}
|
||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||
{/* [S175 — anh: "đưa qua bên phải nhé"] Lưới 2 cột đọc theo chiều NGANG
|
||
(`grid-cols-2` xếp trái→phải), nên THỨ TỰ KHAI quyết định cột.
|
||
Xếp lại để **Hạng mục (dự án) nằm NGAY DƯỚI Dự án ở cột PHẢI** — hai thứ này
|
||
là một cặp (dự án ⟶ gói việc của nó), tách 2 cột thì mắt phải nhảy chéo. */}
|
||
<Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
|
||
<Field label="Dự án" value={plan.projectName ?? '—'} />
|
||
<Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
|
||
{/* [S175 — anh: "hạng mục của Dự án, Duyệt NCC ấy"] Đây là HẠNG MỤC CÔNG VIỆC của
|
||
dự án, lấy từ phiếu Duyệt NCC nguồn (`PE.WorkItemId`).
|
||
🔴 KHÁC hẳn `line.tenHangMuc` — cái đó là danh-mục SP-002 dùng phân loại HỢP
|
||
ĐỒNG (86 dòng, chọn ở mục 2). Hai thứ trùng tên tiếng Việt nhưng khác trục;
|
||
lượt trước lead bày nhầm cái SP-002 ở đây nên anh bảo "Bỏ đi". */}
|
||
<Field label="Hạng mục (dự án)" value={plan.peWorkItemName ?? '—'} />
|
||
<Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
|
||
</div>
|
||
|
||
{/* 🧊 [S175 — anh: "Bỏ đi"] Khối **Link hồ sơ + Ghi chú** (cả ô XEM lẫn form SỬA)
|
||
ĐÃ GỠ. Lượt trước lead mới gỡ ở lưới trên nên 2 ô này VẪN HIỆN ở khối dưới —
|
||
anh chụp lại đúng chỗ đó. Nay gỡ trọn cả 2 nhánh của ternary.
|
||
🔸 Máy KHÔNG mất: hoSoLink / ghiChu còn nguyên trong DTO + BE. */}
|
||
</Section>
|
||
|
||
{/* ─────────────── 2 ─────────────── */}
|
||
{/* [S177 — anh: "Bỏ 1 dòng đi nhé"] Bỏ hậu tố "(N dòng)" khỏi `title`: bảng ngay dưới
|
||
đã có cột STT nên số dòng tự đọc được ở dòng cuối, đếm thêm trên tiêu đề là nói
|
||
hai lần. (Chú thích phải nằm NGOÀI thẻ — JSX không nhận comment giữa attribute.) */}
|
||
<Section
|
||
title="2. Hạng mục"
|
||
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 ? (
|
||
// [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
|
||
// điều-hành (Windows/macOS/Android ra 3 hình), và nó KHÔNG ăn `currentColor`
|
||
// nên luôn lệch tông với chữ amber bên cạnh.
|
||
<span className="inline-flex items-center gap-1 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">
|
||
<Lock className="h-3 w-3" aria-hidden /> Giá đã chốt tại thời điểm duyệt
|
||
</span>
|
||
) : undefined
|
||
}
|
||
>
|
||
{plan.lines.length === 0 ? (
|
||
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa có NCC trúng thầu.</div>
|
||
) : (
|
||
<>
|
||
{/* 🧊 [S175] 2 ô TỔNG (Σ đề xuất ⟂ Σ duyệt) đã GỠ — anh xem trên màn rồi chốt "Bỏ đi".
|
||
Trước đó chính anh chọn "hiện 2 tổng riêng" khi em hỏi; nhìn thật thì thừa. Giữ 1 dòng
|
||
vết để lần sau khỏi ai dựng lại tưởng là thiếu. */}
|
||
{/* [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn
|
||
tự-do trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không
|
||
thẳng hàng giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG
|
||
(đúng ý đồ của `overflow-x-auto` bọc ngoài) thay vì bóp chữ. */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[880px] table-fixed text-xs">
|
||
{/* [S177] 3 → 4 cột (+STT). Bề rộng lấy 6% từ cột hạng mục, KHÔNG đụng 2 cột
|
||
bên phải: cột Giá phải giữ nguyên bề rộng thì số mới còn thẳng hàng giữa
|
||
các phiếu — đó chính là lý do `table-fixed` được chốt ở S175. */}
|
||
<colgroup>
|
||
<col className="w-[6%]" />
|
||
<col className="w-[46%]" />
|
||
<col className="w-[32%]" />
|
||
<col className="w-[16%]" />
|
||
</colgroup>
|
||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||
<tr>
|
||
<th className="label-eyebrow px-3 py-2 text-right">STT</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á</th>
|
||
<th className="label-eyebrow px-3 py-2 text-right"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{plan.lines.map((l, i) => (
|
||
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||
{/* [S177] STT = thứ tự HIỂN THỊ, không phải id nghiệp vụ. BE đã sắp
|
||
`OrderBy(CreatedAt).ThenBy(Id)` nên thứ tự này ổn định giữa 2 lần tải. */}
|
||
<td className="px-3 py-2 text-right align-top tabular-nums text-slate-400">{i + 1}</td>
|
||
<td className="px-3 py-2 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={`Chọn hạng mục cho dòng ${l.id}`}
|
||
className="h-8 w-full 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>
|
||
{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>
|
||
{/* Ghi chú xuống DÒNG PHỤ thay vì chiếm 1 cột — anh chốt bảng chỉ
|
||
còn "hạng mục + giá", nhưng ghi chú thì anh vẫn muốn nhập được. */}
|
||
<input
|
||
type="text"
|
||
maxLength={1000}
|
||
defaultValue={l.note ?? ''}
|
||
placeholder="Ghi chú (tùy chọn)…"
|
||
disabled={saveLineField.isPending}
|
||
aria-label="Ghi chú dòng"
|
||
onBlur={(e) => {
|
||
const v = e.target.value.trim()
|
||
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
||
}}
|
||
className="mt-1 h-7 w-full rounded border border-transparent bg-transparent px-2 text-[11px] text-slate-500 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none"
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* [S177 — anh: "mã hạng mục (Dự án) - mã hạng mục chi tiết"]
|
||
Mã đứng TRÊN tên: mã ngắn + đều nên mắt quét dọc được cả cột,
|
||
còn tên thì dài ngắn khác nhau. `font-mono` để các mã thẳng cột.
|
||
Vắng cả hai mã ⇒ `joinMaHangMuc` trả null ⇒ không vẽ dòng này. */}
|
||
{joinMaHangMuc(plan.peWorkItemCode, l.maHangMuc) && (
|
||
<div className="font-mono text-[11px] font-medium text-slate-500">
|
||
{joinMaHangMuc(plan.peWorkItemCode, l.maHangMuc)}
|
||
</div>
|
||
)}
|
||
<div>{orDash(l.tenHangMuc)}</div>
|
||
{l.note && <div className="mt-0.5 text-[11px] text-slate-500">{l.note}</div>}
|
||
</>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right align-top tabular-nums text-brand-800">
|
||
{isDraft && !l.contractId ? (
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={1000}
|
||
defaultValue={l.proposedAmount}
|
||
disabled={saveLineField.isPending}
|
||
aria-label="Giá của hạng mục"
|
||
onBlur={(e) => {
|
||
const v = Number(e.target.value)
|
||
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
||
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
||
}}
|
||
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||
/>
|
||
) : moneyOrDash(l.proposedAmount)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right align-top">
|
||
{isDraft && !l.contractId && l.peReferenceAmount === 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
||
disabled={deleteLine.isPending}
|
||
title="Xoá hạng mục này"
|
||
className="rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
{/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng.
|
||
🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so
|
||
sánh j với số duyệt PRO hay CCM j cả". Dữ-liệu 2 cột đó VẪN nằm trong DTO
|
||
(`peReferenceAmount` / `approvedAmount`), chỉ bỏ chỗ vẽ; `peReferenceAmount`
|
||
còn được dùng làm DẤU phân biệt dòng-gốc ⟂ dòng-thêm-tay ở nút xoá trên. */}
|
||
<tfoot>
|
||
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
||
{/* [S177] `colSpan={2}` nuốt cột STT mới — thiếu nó thì chữ "Tổng" tụt sang
|
||
ô hạng mục và con số Tổng rơi lệch một cột so với cột Giá bên trên. */}
|
||
<td colSpan={2} className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
|
||
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
|
||
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
|
||
</td>
|
||
<td />
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
{/* [S175 — anh chốt] THÊM HẠNG MỤC cho 1 NCC. 1 NCC được N hạng mục: máy đã cho
|
||
phép từ Mig 71 (unique 3 cột `Plan × Supplier × CatalogEntry`), lượt này chỉ
|
||
bung UI — đúng khuôn "máy rộng, UI hẹp" anh chốt S164.
|
||
🔴 CHỈ liệt kê NCC ĐÃ CÓ trên phiếu: NCC đến từ winner của phiếu Duyệt NCC;
|
||
đẻ NCC mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409). */}
|
||
{isDraft && (
|
||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
|
||
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
|
||
<button
|
||
key={l.supplierId}
|
||
type="button"
|
||
onClick={() => {
|
||
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
|
||
setAddCatalogId(''); setAddAmount(''); setAddNote('')
|
||
}}
|
||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
|
||
>
|
||
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{addFor && (
|
||
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
|
||
<div className="mb-2 text-[12px] font-semibold text-brand-800">
|
||
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
|
||
</div>
|
||
<div className="flex flex-wrap items-end gap-2">
|
||
<label className="min-w-[14rem] flex-1">
|
||
<span className="label-eyebrow">Hạng mục</span>
|
||
<select
|
||
value={addCatalogId}
|
||
onChange={(e) => setAddCatalogId(e.target.value)}
|
||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
|
||
>
|
||
<option value="">— Chọn hạng mục —</option>
|
||
{(catalogOptions.data ?? []).map((c) => (
|
||
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="w-[9rem]">
|
||
<span className="label-eyebrow">Giá đề xuất</span>
|
||
<input
|
||
type="number" min={0} step={1000}
|
||
value={addAmount}
|
||
onChange={(e) => setAddAmount(e.target.value)}
|
||
placeholder="0"
|
||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
|
||
/>
|
||
</label>
|
||
<label className="min-w-[10rem] flex-1">
|
||
<span className="label-eyebrow">Ghi chú</span>
|
||
<input
|
||
type="text" maxLength={1000}
|
||
value={addNote}
|
||
onChange={(e) => setAddNote(e.target.value)}
|
||
placeholder="(tùy chọn)"
|
||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
|
||
/>
|
||
</label>
|
||
<div className="flex gap-1.5">
|
||
<Button
|
||
size="sm"
|
||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
|
||
onClick={() => addLine.mutate({
|
||
supplierId: addFor.supplierId,
|
||
catalogEntryId: addCatalogId,
|
||
proposedAmount: Number(addAmount),
|
||
note: addNote.trim() || undefined,
|
||
})}
|
||
>
|
||
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
|
||
Nội dung không mất: ràng-buộc "phải gán hạng mục mới trình duyệt được" vẫn do
|
||
submit-guard BE chặn thật, và ô Hạng mục ở mục 1 đã đếm "còn N dòng chưa gán". */}
|
||
</Section>
|
||
|
||
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */}
|
||
{/* Anh: "file 3+4 -> Hợp thành file đính kèm như của Duyệt NCC nhé".
|
||
Khuôn dòng file bê từ PE `PeDetailTabs.tsx:3382-3418`: 1 danh sách PHẲNG, mỗi
|
||
dòng có badge cho biết file thuộc căn cứ nào (hoặc "Hồ sơ phiếu" nếu ở mức phiếu).
|
||
🔴 Bảng căn cứ 5 cột bị GỠ khỏi màn, nhưng MÁY GIỮ NGUYÊN: `kind`/`status`/TVGS/
|
||
ghi-chú vẫn sửa được qua nút ✎ (mở đúng dialog cũ, đủ 5 trường) và vẫn hiện dưới
|
||
dạng badge. Gỡ luôn entity để "cho gọn" thì mất dữ-liệu BE đang ghi. */}
|
||
<Section
|
||
title="3. File đính kèm (căn cứ hồ sơ / shopdrawing)"
|
||
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={mergedRows.length}
|
||
actions={
|
||
readOnly || !isDraft ? undefined : (
|
||
<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>
|
||
)
|
||
}
|
||
>
|
||
{/* [S175 — anh chốt] Chỗ tải file bê ĐÚNG khuôn Duyệt NCC (`PeDetailTabs.tsx:3527-3531`
|
||
+ `:3601-3608`): dòng chữ mô-tả ở TRÊN, nút viền ĐỨT nền brand-nhạt ở DƯỚI danh
|
||
sách — thay cho nút đặc nằm ở góc header. Nút viền đứt đọc ra là "chỗ thả thêm
|
||
vào", nút đặc ở header đọc ra là "hành động của cả khối"; đây là cái thứ nhất. */}
|
||
{/* 🧊 [S175 — anh: "Bỏ hết mấy chữ thừa này đi"] Dòng mô-tả "File căn cứ hồ sơ /
|
||
shopdrawing của kế hoạch…" ĐÃ GỠ. Nút ⬆ ở mỗi dòng căn cứ đã tự nói nó làm gì
|
||
(có `title`), không cần một câu hướng-dẫn nằm chình ình trên đầu khối. */}
|
||
{mergedRows.length === 0 ? (
|
||
<div className="text-sm italic text-slate-400">Chưa có căn cứ hồ sơ hay file đính kèm nào.</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
{mergedRows.map((r) => (
|
||
r.rk === 'file' ? (
|
||
<div key={r.att.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
|
||
<span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
|
||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
|
||
<span className={cn(
|
||
'shrink-0 rounded px-1 text-[9px] font-medium',
|
||
r.item ? 'bg-violet-100 text-violet-700' : 'bg-slate-200 text-slate-600',
|
||
)}>
|
||
{r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'}
|
||
</span>
|
||
{r.item && (
|
||
<span className="hidden max-w-[10rem] shrink-0 truncate text-[10px] text-slate-500 sm:inline" title={r.item.name}>
|
||
{r.item.name}
|
||
</span>
|
||
)}
|
||
<span className="shrink-0 text-[10px] text-slate-400">{formatDateTime(r.att.createdAt)}</span>
|
||
{isPreviewable(r.att.fileName) && (
|
||
<button type="button" onClick={() => setPreviewAtt(r.att)} className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50" title="Xem trước">
|
||
<Eye className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
<button type="button" onClick={() => download(r.att)} className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50" title="Tải xuống">
|
||
<Download className="h-3 w-3" />
|
||
</button>
|
||
{!readOnly && (
|
||
<button
|
||
type="button"
|
||
onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
|
||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||
title="Xóa file"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* Căn cứ ĐÃ KHAI mà chưa có file — vẫn phải hiện, không thì khai xong nó
|
||
biến mất và người dùng tưởng bấm hụt. */
|
||
<div key={r.item.id} className="flex items-center gap-1.5 rounded border border-dashed border-slate-200 bg-white px-1.5 py-1 text-[11px]">
|
||
<ClipboardList className="h-3 w-3 shrink-0 text-violet-400" aria-hidden />
|
||
<span className="min-w-0 flex-1 truncate text-slate-600" title={r.item.name}>{r.item.name}</span>
|
||
<span className="shrink-0 rounded bg-violet-100 px-1 text-[9px] font-medium text-violet-700">
|
||
{DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'}
|
||
</span>
|
||
<span className={cn('shrink-0 rounded border px-1 text-[9px] font-medium', DOSSIER_STATUS_BADGE[r.item.status])}>
|
||
{DOSSIER_STATUS_LABELS[r.item.status]}
|
||
</span>
|
||
<span className="shrink-0 text-[10px] italic text-slate-400">chưa có file</span>
|
||
{isDraft && (
|
||
<>
|
||
<label title="Đính kèm file cho căn cứ này" className="shrink-0 cursor-pointer rounded px-1 text-teal-600 hover:bg-teal-50">
|
||
<Upload className="h-3 w-3" />
|
||
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e, r.item.id)} />
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setItemForm({
|
||
id: r.item.id, kind: r.item.kind, supplierId: r.item.supplierId, name: r.item.name,
|
||
status: r.item.status, tvgsName: r.item.tvgsName ?? '', note: r.item.note ?? '',
|
||
})}
|
||
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
|
||
title="Sửa căn cứ"
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { if (window.confirm(`Xóa căn cứ "${r.item.name}"?`)) deleteItem.mutate(r.item.id) }}
|
||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||
title="Xóa căn cứ"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Nút tải lên — viền ĐỨT, đặt DƯỚI danh sách, y khuôn PE `:3601-3608`.
|
||
`accept` bê nguyên danh-sách của PE: chặn ngay ở hộp chọn file đỡ được vòng
|
||
upload-rồi-mới-biết-sai-định-dạng. */}
|
||
{!readOnly && (
|
||
<label className={cn(
|
||
'mt-2 inline-flex cursor-pointer items-center gap-1.5 rounded border border-dashed border-brand-300',
|
||
'bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700',
|
||
'hover:border-brand-500 hover:bg-brand-50',
|
||
upload.isPending && 'pointer-events-none opacity-50',
|
||
)}>
|
||
<Upload className="h-3.5 w-3.5" />
|
||
{upload.isPending ? 'Đang tải…' : '+ Tải lên hồ sơ'}
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||
className="hidden"
|
||
onChange={(e) => onPickFiles(e)}
|
||
/>
|
||
</label>
|
||
)}
|
||
{previewAtt && (
|
||
<AttachmentPreviewDialog
|
||
open
|
||
basePath="/contract-signing-plans"
|
||
evaluationId={plan.id}
|
||
attachmentId={previewAtt.id}
|
||
fileName={previewAtt.fileName}
|
||
onClose={() => setPreviewAtt(null)}
|
||
/>
|
||
)}
|
||
</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>
|
||
|
||
{/* [S175 — anh: "phía dưới thêm các nút thao tác"] THANH NÚT ĐÁY, khuôn PE workspace.
|
||
Gom về ĐÂY những nút anh bảo gỡ khỏi chỗ khác: "Gửi duyệt" + "Xóa phiếu" (trước ở
|
||
khối HÀNH ĐỘNG panel 3). Panel 3 từ nay chỉ còn **Duyệt / Trả lại** — đúng câu anh
|
||
chốt "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi".
|
||
🔴 `sticky bottom-0`: phiếu dài, nút nằm cuối trang thì phải cuộn hết mới thấy —
|
||
dính đáy là để lúc nào cũng với tới được.
|
||
🔸 KHÔNG có nút "Lưu" rời: mọi ô ở màn này lưu NGAY khi rời ô (hạng mục / giá / ghi
|
||
chú / file). Thêm một nút "Lưu" không làm gì là hứa hão — nói thẳng bằng dòng chữ
|
||
bên cạnh thay vì vẽ nút giả. */}
|
||
{!readOnly && (
|
||
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (window.confirm(`Xóa phiếu ${plan.maKeHoach ?? ''}? Phiếu chuyển sang mục "Đã xóa".`)) deletePlan.mutate()
|
||
}}
|
||
disabled={deletePlan.isPending}
|
||
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
{deletePlan.isPending ? 'Đang xóa…' : 'Xóa phiếu'}
|
||
</button>
|
||
|
||
<span className="min-w-0 flex-1 text-[12px] text-emerald-700">
|
||
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
|
||
</span>
|
||
|
||
{(() => {
|
||
// Rào HIỂN THỊ mirror submit-guard BE: chưa pin quy trình, hoặc còn dòng chưa gán
|
||
// hạng mục ⇒ BE sẽ chặn. Nói TRƯỚC lý do thay vì để bấm rồi ăn 409.
|
||
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
||
const noWorkflow = !plan.approvalWorkflowId
|
||
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
|
||
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
|
||
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
|
||
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
|
||
: undefined
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => { if (!blocked) submitPlan.mutate() }}
|
||
disabled={blocked || submitPlan.isPending}
|
||
title={why ?? 'Gửi phiếu vào quy trình duyệt'}
|
||
className={cn(
|
||
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold text-white transition',
|
||
blocked ? 'cursor-not-allowed bg-slate-300' : 'bg-brand-600 hover:bg-brand-700',
|
||
submitPlan.isPending && 'opacity-50',
|
||
)}
|
||
>
|
||
{submitPlan.isPending ? 'Đang gửi…' : 'Lưu & Gửi duyệt →'}
|
||
</button>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|