[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*) - FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list - Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau) - tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
680
fe-admin/src/pages/khkk/KhkkDetailPage.tsx
Normal file
680
fe-admin/src/pages/khkk/KhkkDetailPage.tsx
Normal file
@ -0,0 +1,680 @@
|
||||
// [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).
|
||||
// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3.
|
||||
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, 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 { 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_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): 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 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)),
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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={
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
{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>
|
||||
<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 ?? '—'}</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.
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user