[CLAUDE] Phase2: Form Engine MVP + docs (gotchas, skill, handoff)
Backend Forms:
- Domain/Forms: ContractTemplate (FormCode, Name, ContractType, FileName, StoragePath, Format, FieldSpec JSON, IsActive) + ContractClause
- EF config voi unique FormCode + query filter IsDeleted
- DbSets + IApplicationDbContext update
- Migration AddForms (bang 14 total)
- Packages: DocumentFormat.OpenXml 3.x + ClosedXML 0.105+
- Application/Forms:
- IFormRenderer interface + RenderResult record
- FormFeatures.cs: List/Get/Render CQRS
- IWebHostEnvironmentLocator (abstract IWebHostEnvironment)
- Infrastructure/Forms:
- DocxRenderer: OpenXml-based placeholder {{field}} replace, handle split runs (gom text tat ca <w:t> trong paragraph, replace, gan lai text dau + clear rest)
- XlsxRenderer: ClosedXML cell value replace
- FormRenderer router theo format docx/xlsx
- Api:
- FormsController: GET /templates (filter type, onlyActive), GET /templates/{id}, POST /templates/{id}/render (return file)
- WebHostEnvironmentLocator impl
- DbInitializer SeedContractTemplatesAsync: seed 8 template metadata, IsActive=true chi khi file ton tai
Templates vat ly:
- Copy 5 .docx/.xlsx tu FORM/ sang wwwroot/templates/
- 3 .doc (FO-002.02/03/06) chua convert: IsActive=false (Word COM bi stuck luc test, can retry voi DisplayAlerts=0 hoac LibreOffice)
- scripts/convert-doc-to-docx.ps1 (Word COM automation)
Frontend fe-admin:
- types/forms.ts: ContractTemplate + ContractTypeLabel
- pages/forms/FormsPage.tsx: list templates + Render dialog (paste JSON data → download .docx/.xlsx)
- Route /forms them vao App.tsx
Bug fix:
- SpaceProcessingModeValues namespace: wrap EnumValue<> full path
- SaveAs2($path, 16) thay vi SaveAs([ref], [ref]) — PowerShell type issue
- Word COM stuck: kill process, skip .doc cho MVP
Docs (theo yeu cau user):
- docs/gotchas.md MOI: 17 pitfalls nhom theo tech stack / EF Core / OpenXml / JSON / dev workflow
- .claude/skills/form-engine/SKILL.md: placeholder → full spec (algorithm + code pointers + API + limitations)
- .claude/skills/permission-matrix/SKILL.md: placeholder → full spec (BE policy + FE guard + seed + pitfalls)
- docs/HANDOFF.md MOI: brief 5 phut cho session sau (run quickstart + where we are + next steps + file tree + gotchas ref)
- docs/STATUS.md: update cumulative stats + next up Phase 3
- docs/changelog/migration-todos.md: tick Phase 2 iteration 1 items + add iteration 2 list
- docs/changelog/sessions/2026-04-21-1200-phase2-form-engine.md: session log
- CLAUDE.md root: them reference den gotchas + HANDOFF
E2E verified:
- GET /api/forms/templates (onlyActive=false) → 8 templates
- POST /api/forms/templates/{FO-002.05}/render voi data dict → HTTP 200 + file .docx 482KB (Microsoft Word 2007+ OK)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -9,6 +9,7 @@ import { SuppliersPage } from '@/pages/master/SuppliersPage'
|
||||
import { ProjectsPage } from '@/pages/master/ProjectsPage'
|
||||
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
|
||||
import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -28,6 +29,7 @@ function App() {
|
||||
<Route path="/master/projects" element={<ProjectsPage />} />
|
||||
<Route path="/master/departments" element={<DepartmentsPage />} />
|
||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
142
fe-admin/src/pages/forms/FormsPage.tsx
Normal file
142
fe-admin/src/pages/forms/FormsPage.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Download, FileSpreadsheet, FileText, CheckCircle2, XCircle } 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 { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
|
||||
|
||||
export function FormsPage() {
|
||||
const [dialog, setDialog] = useState<ContractTemplate | null>(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')
|
||||
setDialog(null)
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
function handleRender() {
|
||||
if (!dialog) 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: dialog.id, data })
|
||||
}
|
||||
|
||||
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-blue-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" />
|
||||
Chưa active
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: 'w-32',
|
||||
render: t => (
|
||||
<Button size="sm" variant="outline" disabled={!t.isActive} onClick={() => setDialog(t)}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Render
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<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."
|
||||
/>
|
||||
|
||||
<DataTable columns={columns} rows={list.data ?? []} getRowKey={t => t.id} isLoading={list.isLoading} />
|
||||
|
||||
<Dialog
|
||||
open={!!dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
title={dialog ? `Render: ${dialog.name}` : ''}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDialog(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={dialog?.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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
fe-admin/src/types/forms.ts
Normal file
21
fe-admin/src/types/forms.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export type ContractTemplate = {
|
||||
id: string
|
||||
formCode: string
|
||||
name: string
|
||||
contractType: number | null
|
||||
fileName: string
|
||||
format: 'docx' | 'xlsx'
|
||||
fieldSpec: string | null
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const ContractTypeLabel: Record<number, string> = {
|
||||
1: 'HĐ Thầu phụ',
|
||||
2: 'HĐ Giao khoán',
|
||||
3: 'HĐ Nhà cung cấp',
|
||||
4: 'HĐ Dịch vụ',
|
||||
5: 'HĐ Mua bán',
|
||||
6: 'HĐ Nguyên tắc NCC',
|
||||
7: 'HĐ Nguyên tắc Dịch vụ',
|
||||
}
|
||||
Reference in New Issue
Block a user