All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m44s
Admin giờ có thể quản lý template HĐ hoàn toàn qua UI — không cần dev
đụng vào file system hay seed data.
BE (FormFeatures.cs + FormsController.cs):
- UploadContractTemplateCommand (multipart): validate FormCode
(regex [A-Za-z0-9._-]+, unique), file <= 10MB, ext .docx/.xlsx,
FieldSpec phải là JSON hợp lệ hoặc null. Ghi file vào
wwwroot/templates/{formCode}_{guid:N}.{ext} để tránh collision
+ path traversal.
- UpdateContractTemplateCommand: sửa metadata + FieldSpec + IsActive
(không đụng file — chỉ DB).
- DeleteContractTemplateCommand: soft delete qua IsActive=false
(historical contracts ref template này vẫn resolve).
- Endpoints: POST /api/forms/templates (multipart),
PUT /api/forms/templates/{id}, DELETE /api/forms/templates/{id}.
RequestSizeLimit 12MB (validator caps 10MB).
FE (FormsPage.tsx admin):
- PageHeader action button "Upload template" mở dialog mới
- Row actions: Download (render existing), Pencil (edit), Trash (xóa
confirm) thay vì chỉ có 1 nút Render — row hover reveals clearly
- Upload dialog: file picker với file: pseudo-element brand styled,
FormCode (required, font-mono), Tên, Loại HĐ select, Mô tả,
FieldSpec JSON textarea với placeholder example
- Edit dialog: same fields minus file (FormCode disabled, edit chỉ
cập nhật metadata), có checkbox Kích hoạt
- Shared form submit handler — same dialog cho upload (__new) + edit
Foundation sẵn cho form builder thật (render UI từ FieldSpec JSON
đang là text field — iteration sau sẽ parse + render form dynamic).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { useRef, useState, type FormEvent } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
Download,
|
|
FileSpreadsheet,
|
|
FileText,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Upload,
|
|
Pencil,
|
|
Trash2,
|
|
Plus,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { PageHeader } from '@/components/PageHeader'
|
|
import { DataTable, type Column } from '@/components/DataTable'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Dialog } from '@/components/ui/Dialog'
|
|
import { Input } from '@/components/ui/Input'
|
|
import { Label } from '@/components/ui/Label'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { Textarea } from '@/components/ui/Textarea'
|
|
import { api } from '@/lib/api'
|
|
import { getErrorMessage } from '@/lib/apiError'
|
|
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
|
|
|
|
type EditState = ContractTemplate & { __new?: boolean }
|
|
|
|
const EMPTY_EDIT: EditState = {
|
|
id: '',
|
|
formCode: '',
|
|
name: '',
|
|
contractType: null,
|
|
fileName: '',
|
|
format: 'docx',
|
|
fieldSpec: null,
|
|
description: null,
|
|
isActive: true,
|
|
__new: true,
|
|
}
|
|
|
|
export function FormsPage() {
|
|
const qc = useQueryClient()
|
|
const [renderDialog, setRenderDialog] = useState<ContractTemplate | null>(null)
|
|
const [edit, setEdit] = useState<EditState | null>(null)
|
|
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [dataJson, setDataJson] = useState<string>(`{
|
|
"benA_tenCongTy": "Công ty TNHH Xây dựng Solutions",
|
|
"giaTri": "150,000,000 VND",
|
|
"ngayKy": "21/04/2026"
|
|
}`)
|
|
|
|
const list = useQuery({
|
|
queryKey: ['contract-templates'],
|
|
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { onlyActive: false } })).data,
|
|
})
|
|
|
|
const render = useMutation({
|
|
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
|
|
const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' })
|
|
return {
|
|
blob: res.data as Blob,
|
|
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx',
|
|
}
|
|
},
|
|
onSuccess: ({ blob, filename }) => {
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
toast.success('Đã tải file render')
|
|
setRenderDialog(null)
|
|
},
|
|
onError: err => toast.error(getErrorMessage(err)),
|
|
})
|
|
|
|
const upload = useMutation({
|
|
mutationFn: async (params: { file: File; meta: EditState }) => {
|
|
const form = new FormData()
|
|
form.append('file', params.file)
|
|
form.append('formCode', params.meta.formCode)
|
|
form.append('name', params.meta.name)
|
|
if (params.meta.contractType !== null) form.append('contractType', String(params.meta.contractType))
|
|
if (params.meta.description) form.append('description', params.meta.description)
|
|
if (params.meta.fieldSpec) form.append('fieldSpec', params.meta.fieldSpec)
|
|
await api.post('/forms/templates', form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['contract-templates'] })
|
|
toast.success('Đã upload template mới')
|
|
setEdit(null)
|
|
setUploadFile(null)
|
|
},
|
|
onError: err => toast.error(getErrorMessage(err)),
|
|
})
|
|
|
|
const update = useMutation({
|
|
mutationFn: async (meta: EditState) =>
|
|
api.put(`/forms/templates/${meta.id}`, {
|
|
name: meta.name,
|
|
contractType: meta.contractType,
|
|
description: meta.description,
|
|
fieldSpec: meta.fieldSpec,
|
|
isActive: meta.isActive,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['contract-templates'] })
|
|
toast.success('Đã cập nhật template')
|
|
setEdit(null)
|
|
},
|
|
onError: err => toast.error(getErrorMessage(err)),
|
|
})
|
|
|
|
const del = useMutation({
|
|
mutationFn: async (id: string) => api.delete(`/forms/templates/${id}`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['contract-templates'] })
|
|
toast.success('Đã vô hiệu hóa template')
|
|
},
|
|
onError: err => toast.error(getErrorMessage(err)),
|
|
})
|
|
|
|
function handleRender() {
|
|
if (!renderDialog) return
|
|
let data: Record<string, string | null>
|
|
try {
|
|
data = JSON.parse(dataJson)
|
|
} catch {
|
|
toast.error('JSON không hợp lệ')
|
|
return
|
|
}
|
|
render.mutate({ id: renderDialog.id, data })
|
|
}
|
|
|
|
function handleSaveEdit(e: FormEvent) {
|
|
e.preventDefault()
|
|
if (!edit) return
|
|
if (edit.__new) {
|
|
if (!uploadFile) {
|
|
toast.error('Chưa chọn file template .docx/.xlsx')
|
|
return
|
|
}
|
|
upload.mutate({ file: uploadFile, meta: edit })
|
|
} else {
|
|
update.mutate(edit)
|
|
}
|
|
}
|
|
|
|
const columns: Column<ContractTemplate>[] = [
|
|
{
|
|
key: 'format',
|
|
header: '',
|
|
width: 'w-12',
|
|
align: 'center',
|
|
render: t =>
|
|
t.format === 'xlsx' ? (
|
|
<FileSpreadsheet className="mx-auto h-4 w-4 text-emerald-600" />
|
|
) : (
|
|
<FileText className="mx-auto h-4 w-4 text-brand-600" />
|
|
),
|
|
},
|
|
{ key: 'formCode', header: 'Form Code', render: t => <span className="font-mono text-xs">{t.formCode}</span>, width: 'w-40' },
|
|
{ key: 'name', header: 'Tên', render: t => t.name },
|
|
{
|
|
key: 'contractType',
|
|
header: 'Loại HĐ',
|
|
render: t => (t.contractType ? ContractTypeLabel[t.contractType] : '—'),
|
|
width: 'w-40',
|
|
},
|
|
{
|
|
key: 'isActive',
|
|
header: 'Trạng thái',
|
|
width: 'w-28',
|
|
align: 'center',
|
|
render: t =>
|
|
t.isActive ? (
|
|
<CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" />
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-xs text-amber-600">
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
Tắt
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
align: 'right',
|
|
width: 'w-56',
|
|
render: t => (
|
|
<div className="flex justify-end gap-1">
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
setRenderDialog(t)
|
|
}}
|
|
disabled={!t.isActive}
|
|
className="flex h-7 w-7 items-center justify-center rounded text-slate-500 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-40"
|
|
title="Render"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
setEdit({ ...t })
|
|
}}
|
|
className="flex h-7 w-7 items-center justify-center rounded text-slate-500 hover:bg-slate-100 hover:text-slate-700"
|
|
title="Chỉnh sửa"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
{t.isActive && (
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
if (window.confirm(`Vô hiệu hóa template "${t.name}"?`)) del.mutate(t.id)
|
|
}}
|
|
className="flex h-7 w-7 items-center justify-center rounded text-slate-500 hover:bg-red-50 hover:text-red-600"
|
|
title="Xóa"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<PageHeader
|
|
title="Biểu mẫu hợp đồng"
|
|
description="Template HĐ hệ thống. Upload mới .docx/.xlsx, edit FieldSpec JSON, render để tải file điền dữ liệu."
|
|
actions={
|
|
<Button onClick={() => setEdit(EMPTY_EDIT)}>
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
Upload template
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<DataTable columns={columns} rows={list.data ?? []} getRowKey={t => t.id} isLoading={list.isLoading} />
|
|
|
|
{/* Render dialog */}
|
|
<Dialog
|
|
open={!!renderDialog}
|
|
onClose={() => setRenderDialog(null)}
|
|
title={renderDialog ? `Render: ${renderDialog.name}` : ''}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setRenderDialog(null)}>
|
|
Hủy
|
|
</Button>
|
|
<Button onClick={handleRender} disabled={render.isPending}>
|
|
{render.isPending ? 'Đang render…' : 'Render & tải xuống'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
|
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng{' '}
|
|
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền key-value JSON dưới đây, backend sẽ
|
|
replace placeholder trong file gốc.
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Form Code</Label>
|
|
<Input value={renderDialog?.formCode ?? ''} disabled />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Data JSON (placeholder → value)</Label>
|
|
<Textarea
|
|
rows={10}
|
|
value={dataJson}
|
|
onChange={e => setDataJson(e.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Upload / Edit dialog */}
|
|
<Dialog
|
|
open={!!edit}
|
|
onClose={() => {
|
|
setEdit(null)
|
|
setUploadFile(null)
|
|
}}
|
|
title={edit?.__new ? 'Upload template mới' : `Chỉnh sửa: ${edit?.name ?? ''}`}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEdit(null)
|
|
setUploadFile(null)
|
|
}}
|
|
>
|
|
Hủy
|
|
</Button>
|
|
<Button
|
|
onClick={handleSaveEdit}
|
|
disabled={upload.isPending || update.isPending}
|
|
type="submit"
|
|
form="template-edit-form"
|
|
>
|
|
{upload.isPending || update.isPending
|
|
? 'Đang lưu…'
|
|
: edit?.__new
|
|
? 'Upload'
|
|
: 'Lưu thay đổi'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{edit && (
|
|
<form id="template-edit-form" onSubmit={handleSaveEdit} className="space-y-4">
|
|
{edit.__new && (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="file">File template (.docx / .xlsx)</Label>
|
|
<input
|
|
id="file"
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".docx,.xlsx"
|
|
onChange={e => setUploadFile(e.target.files?.[0] ?? null)}
|
|
className="block w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm file:mr-3 file:rounded file:border-0 file:bg-brand-50 file:px-3 file:py-1 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
|
/>
|
|
{uploadFile && (
|
|
<div className="text-xs text-slate-500">
|
|
{uploadFile.name} · {(uploadFile.size / 1024).toFixed(1)} KB
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="formCode">Form Code *</Label>
|
|
<Input
|
|
id="formCode"
|
|
value={edit.formCode}
|
|
onChange={e => setEdit({ ...edit, formCode: e.target.value })}
|
|
disabled={!edit.__new}
|
|
placeholder="SOL-CCM-FO-XXX"
|
|
className="font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="contractType">Loại HĐ</Label>
|
|
<Select
|
|
id="contractType"
|
|
value={edit.contractType ?? ''}
|
|
onChange={e =>
|
|
setEdit({ ...edit, contractType: e.target.value === '' ? null : Number(e.target.value) })
|
|
}
|
|
>
|
|
<option value="">— (phụ lục / điều kiện chung)</option>
|
|
{Object.entries(ContractTypeLabel).map(([k, v]) => (
|
|
<option key={k} value={k}>
|
|
{v}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="name">Tên template *</Label>
|
|
<Input
|
|
id="name"
|
|
value={edit.name}
|
|
onChange={e => setEdit({ ...edit, name: e.target.value })}
|
|
placeholder="VD: Hợp đồng Giao khoán"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="description">Mô tả</Label>
|
|
<Textarea
|
|
id="description"
|
|
rows={2}
|
|
value={edit.description ?? ''}
|
|
onChange={e => setEdit({ ...edit, description: e.target.value || null })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="fieldSpec">FieldSpec JSON (optional)</Label>
|
|
<Textarea
|
|
id="fieldSpec"
|
|
rows={8}
|
|
value={edit.fieldSpec ?? ''}
|
|
onChange={e => setEdit({ ...edit, fieldSpec: e.target.value || null })}
|
|
placeholder={`{\n "benA_tenCongTy": { "label": "Tên Cty bên A", "type": "text", "required": true },\n "giaTri": { "label": "Giá trị", "type": "currency" }\n}`}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div className="text-xs text-slate-400">
|
|
Mô tả các field trong template. Future iteration sẽ render form builder từ spec này.
|
|
</div>
|
|
</div>
|
|
|
|
{!edit.__new && (
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={edit.isActive}
|
|
onChange={e => setEdit({ ...edit, isActive: e.target.checked })}
|
|
className="h-4 w-4 accent-brand-600"
|
|
/>
|
|
<span>Kích hoạt (hiển thị khi tạo HĐ)</span>
|
|
</label>
|
|
)}
|
|
</form>
|
|
)}
|
|
</Dialog>
|
|
|
|
<div className="mt-4 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
|
|
<Upload className="h-3.5 w-3.5" />
|
|
File template lưu tại <code className="rounded bg-white px-1">wwwroot/templates/</code>, placeholder cú pháp{' '}
|
|
<code className="rounded bg-white px-1">{'{{fieldKey}}'}</code>.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|