[CLAUDE] App+Infra+FE-Admin: DynamicForm + .doc/.xls auto-convert on upload
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m17s

Tier 3 iter2 — form builder UI dùng FieldSpec thay raw JSON textarea.

FE:
- DynamicForm component — parse FieldSpec JSON (record of FieldDef với
  label/type/required/placeholder/hint/options) và render inputs
  dynamic theo type: text/textarea/number/date/currency/select.
- FormsPage render dialog thêm toggle Form ↔ JSON (segmented control).
  Mặc định Form mode khi template có FieldSpec, JSON mode khi không.
  Khi mở dialog cho row khác, reset formValues + chọn đúng default mode.
- parseFieldSpec helper trả { spec, error } — UI báo lỗi nếu JSON
  không parse được, fallback JSON textarea.

BE — generalize converter thành IDocumentConverter:
- IPdfConverter → IDocumentConverter (ConvertAsync(bytes, src, tgt, ct))
  — đủ gánh cả pdf, docx, xlsx targets.
- LibreOfficeDocumentConverter — 1 shell-out pattern cho mọi conversion
  (docx→pdf, doc→docx, xls→xlsx, xlsx→pdf), target arg truyền vào
  --convert-to.
- ExportTemplatePdfCommand update dùng "pdf" target.

Auto-convert .doc/.xls trên upload:
- Validator accept thêm .doc/.xls (thêm note "sẽ tự convert").
- UploadContractTemplateCommandHandler: nếu ext là doc/xls → read stream
  → converter.ConvertAsync → lưu file .docx/.xlsx thay vì format gốc.
  File rendering pipeline (DocxRenderer/XlsxRenderer) chỉ support docx/
  xlsx — convert đảm bảo consistent.
- Display FileName preserve original name nhưng đổi extension.

Unblock 3 file .doc legacy template — admin giờ upload .doc bình thường,
system tự convert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 21:35:05 +07:00
parent 6bbd894d96
commit e45909712b
6 changed files with 300 additions and 56 deletions

View File

@ -20,6 +20,7 @@ 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 { DynamicForm, DynamicFormPreviewError, parseFieldSpec, type DynamicFormValues } from '@/components/DynamicForm'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
@ -50,6 +51,8 @@ export function FormsPage() {
"giaTri": "150,000,000 VND",
"ngayKy": "21/04/2026"
}`)
const [dataMode, setDataMode] = useState<'form' | 'json'>('form')
const [formValues, setFormValues] = useState<DynamicFormValues>({})
const list = useQuery({
queryKey: ['contract-templates'],
@ -131,6 +134,9 @@ export function FormsPage() {
function parseJsonOrToast(): Record<string, string | null> | null {
if (!renderDialog) return null
// Form mode uses the dynamic-form values directly; only parse JSON when
// the user is on the JSON tab.
if (dataMode === 'form') return formValues
try {
return JSON.parse(dataJson)
} catch {
@ -211,6 +217,10 @@ export function FormsPage() {
<button
onClick={e => {
e.stopPropagation()
// Reset form values + default to Form mode when FieldSpec present
const parsed = parseFieldSpec(t.fieldSpec)
setFormValues({})
setDataMode(parsed.spec && Object.keys(parsed.spec).length > 0 ? 'form' : 'json')
setRenderDialog(t)
}}
disabled={!t.isActive}
@ -285,26 +295,70 @@ 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 JSON bên dưới. <strong>Tải file gốc</strong>{' '}
đ chỉnh sửa trong Word/Excel, <strong>Tải PDF</strong> đ in/gi không chnh sa đư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>
{(() => {
const parsed = parseFieldSpec(renderDialog?.fieldSpec)
const hasSpec = parsed.spec && Object.keys(parsed.spec).length > 0
return (
<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> Placeholder dạng{' '}
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. <strong>Tải file gốc</strong> đ edit
Word/Excel, <strong>Tải PDF</strong> đ in/gi.
</div>
<div className="flex items-center justify-between">
<div className="space-y-1.5 flex-1">
<Label>Form Code</Label>
<Input value={renderDialog?.formCode ?? ''} disabled />
</div>
{hasSpec && (
<div className="ml-4 flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-xs">
<button
type="button"
onClick={() => setDataMode('form')}
className={`rounded px-3 py-1.5 font-medium transition ${
dataMode === 'form' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Form
</button>
<button
type="button"
onClick={() => setDataMode('json')}
className={`rounded px-3 py-1.5 font-medium transition ${
dataMode === 'json' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
JSON
</button>
</div>
)}
</div>
{parsed.error && <DynamicFormPreviewError error={parsed.error} />}
{hasSpec && dataMode === 'form' ? (
<DynamicForm
spec={parsed.spec!}
values={formValues}
onChange={(k, v) => setFormValues(prev => ({ ...prev, [k]: v }))}
/>
) : (
<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"
/>
{!hasSpec && (
<div className="text-[11px] text-slate-400">
Template này chưa FieldSpec dùng JSON tự do. Thêm FieldSpec vào template đ dùng form builder.
</div>
)}
</div>
)}
</div>
)
})()}
</Dialog>
{/* Upload / Edit dialog */}