[CLAUDE] App+Api+FE-Admin: Form template builder (upload + edit + delete)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m44s
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>
This commit is contained in:
@ -1,6 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Download, FileSpreadsheet, FileText, CheckCircle2, XCircle } from 'lucide-react'
|
||||
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'
|
||||
@ -8,13 +18,33 @@ 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 [dialog, setDialog] = useState<ContractTemplate | null>(null)
|
||||
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",
|
||||
@ -29,7 +59,10 @@ export function FormsPage() {
|
||||
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' }
|
||||
return {
|
||||
blob: res.data as Blob,
|
||||
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx',
|
||||
}
|
||||
},
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
@ -39,13 +72,59 @@ export function FormsPage() {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Đã tải file render')
|
||||
setDialog(null)
|
||||
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 (!dialog) return
|
||||
if (!renderDialog) return
|
||||
let data: Record<string, string | null>
|
||||
try {
|
||||
data = JSON.parse(dataJson)
|
||||
@ -53,7 +132,21 @@ export function FormsPage() {
|
||||
toast.error('JSON không hợp lệ')
|
||||
return
|
||||
}
|
||||
render.mutate({ id: dialog.id, data })
|
||||
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>[] = [
|
||||
@ -63,11 +156,20 @@ export function FormsPage() {
|
||||
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-blue-600" />,
|
||||
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: 'contractType',
|
||||
header: 'Loại HĐ',
|
||||
render: t => (t.contractType ? ContractTypeLabel[t.contractType] : '—'),
|
||||
width: 'w-40',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Trạng thái',
|
||||
@ -79,7 +181,7 @@ export function FormsPage() {
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Chưa active
|
||||
Tắt
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@ -87,12 +189,43 @@ export function FormsPage() {
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: 'w-32',
|
||||
width: 'w-56',
|
||||
render: t => (
|
||||
<Button size="sm" variant="outline" disabled={!t.isActive} onClick={() => setDialog(t)}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Render
|
||||
</Button>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]
|
||||
@ -101,19 +234,26 @@ export function FormsPage() {
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title="Biểu mẫu hợp đồng"
|
||||
description="Danh sách template HĐ. Click 'Render' để test điền field {{key}} và tải file .docx/.xlsx."
|
||||
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={!!dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
title={dialog ? `Render: ${dialog.name}` : ''}
|
||||
open={!!renderDialog}
|
||||
onClose={() => setRenderDialog(null)}
|
||||
title={renderDialog ? `Render: ${renderDialog.name}` : ''}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDialog(null)}>
|
||||
<Button variant="outline" onClick={() => setRenderDialog(null)}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button onClick={handleRender} disabled={render.isPending}>
|
||||
@ -124,19 +264,170 @@ export function FormsPage() {
|
||||
>
|
||||
<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.
|
||||
<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={dialog?.formCode ?? ''} disabled />
|
||||
<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" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user