From 166d26c1d8be4f1eeb57bbfb82948aab9e3e15e2 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 21:15:35 +0700 Subject: [PATCH] [CLAUDE] App+Api+FE-Admin: Form template builder (upload + edit + delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fe-admin/src/pages/forms/FormsPage.tsx | 341 ++++++++++++++++-- .../Controllers/FormsController.cs | 46 +++ .../Forms/FormFeatures.cs | 153 ++++++++ 3 files changed, 515 insertions(+), 25 deletions(-) diff --git a/fe-admin/src/pages/forms/FormsPage.tsx b/fe-admin/src/pages/forms/FormsPage.tsx index 0787481..a0def56 100644 --- a/fe-admin/src/pages/forms/FormsPage.tsx +++ b/fe-admin/src/pages/forms/FormsPage.tsx @@ -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(null) + const qc = useQueryClient() + const [renderDialog, setRenderDialog] = useState(null) + const [edit, setEdit] = useState(null) + const [uploadFile, setUploadFile] = useState(null) + const fileInputRef = useRef(null) const [dataJson, setDataJson] = useState(`{ "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 }) => { 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 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[] = [ @@ -63,11 +156,20 @@ export function FormsPage() { width: 'w-12', align: 'center', render: t => - t.format === 'xlsx' ? : , + t.format === 'xlsx' ? ( + + ) : ( + + ), }, { key: 'formCode', header: 'Form Code', render: t => {t.formCode}, 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() { ) : ( - Chưa active + Tắt ), }, @@ -87,12 +189,43 @@ export function FormsPage() { key: 'actions', header: '', align: 'right', - width: 'w-32', + width: 'w-56', render: t => ( - +
+ + + {t.isActive && ( + + )} +
), }, ] @@ -101,19 +234,26 @@ export function FormsPage() {
setEdit(EMPTY_EDIT)}> + + Upload template + + } /> t.id} isLoading={list.isLoading} /> + {/* Render dialog */} setDialog(null)} - title={dialog ? `Render: ${dialog.name}` : ''} + open={!!renderDialog} + onClose={() => setRenderDialog(null)} + title={renderDialog ? `Render: ${renderDialog.name}` : ''} size="lg" footer={ <> -