Files
solution-erp/fe-admin/src/pages/forms/FormsPage.tsx
pqhuy1987 166d26c1d8
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m44s
[CLAUDE] App+Api+FE-Admin: Form template builder (upload + edit + delete)
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>
2026-04-21 21:15:35 +07:00

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 </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"> 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">
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 )</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 pháp{' '}
<code className="rounded bg-white px-1">{'{{fieldKey}}'}</code>.
</div>
</div>
)
}