[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:
pqhuy1987
2026-04-21 12:01:11 +07:00
parent 54d6c9ba52
commit 5113e4c771
37 changed files with 2379 additions and 88 deletions

View File

@ -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="*"

View 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>
)
}

View 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ụ',
}