[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

@ -0,0 +1,150 @@
import { useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
// FieldSpec schema — stored on ContractTemplate.FieldSpec as JSON string.
// Designed to be human-authorable by admin in JSON textarea but also machine-
// readable for the DynamicForm renderer below.
//
// {
// "fieldKey": {
// "label": "Tên Cty bên A",
// "type": "text" | "textarea" | "number" | "date" | "currency" | "select",
// "required": false,
// "placeholder": "...",
// "hint": "...",
// "options": ["opt1", "opt2"] // only for type=select
// }
// }
export type FieldDef = {
label: string
type?: 'text' | 'textarea' | 'number' | 'date' | 'currency' | 'select'
required?: boolean
placeholder?: string
hint?: string
options?: string[]
}
export type FieldSpec = Record<string, FieldDef>
export type DynamicFormValues = Record<string, string | null>
export function parseFieldSpec(json: string | null | undefined): { spec: FieldSpec | null; error: string | null } {
if (!json || !json.trim()) return { spec: null, error: null }
try {
const obj = JSON.parse(json)
if (typeof obj !== 'object' || obj === null || Array.isArray(obj))
return { spec: null, error: 'FieldSpec phải là object JSON' }
return { spec: obj as FieldSpec, error: null }
} catch (e) {
return { spec: null, error: `JSON lỗi: ${(e as Error).message}` }
}
}
export function DynamicForm({
spec,
values,
onChange,
}: {
spec: FieldSpec
values: DynamicFormValues
onChange: (key: string, value: string | null) => void
}) {
const entries = useMemo(() => Object.entries(spec), [spec])
if (entries.length === 0) {
return (
<div className="rounded-md border border-dashed border-slate-200 bg-slate-50 p-6 text-center text-sm text-slate-400">
FieldSpec trống thêm field vào spec JSON đ hiện form.
</div>
)
}
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{entries.map(([key, def]) => {
const type = def.type ?? 'text'
const val = values[key] ?? ''
const commonProps = {
id: key,
value: val,
placeholder: def.placeholder,
required: def.required,
}
const spanClass = type === 'textarea' ? 'md:col-span-2' : ''
return (
<div key={key} className={`space-y-1.5 ${spanClass}`}>
<Label htmlFor={key} className="flex items-center gap-1">
{def.label}
{def.required && <span className="text-red-500">*</span>}
<span className="ml-auto font-mono text-[10px] font-normal text-slate-400">{key}</span>
</Label>
{type === 'textarea' && (
<Textarea
{...commonProps}
rows={3}
onChange={e => onChange(key, e.target.value || null)}
/>
)}
{type === 'select' && (
<Select {...commonProps} onChange={e => onChange(key, e.target.value || null)}>
<option value=""> chọn </option>
{(def.options ?? []).map(opt => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</Select>
)}
{type === 'number' && (
<Input
{...commonProps}
type="number"
inputMode="numeric"
onChange={e => onChange(key, e.target.value || null)}
/>
)}
{type === 'currency' && (
<Input
{...commonProps}
type="text"
inputMode="numeric"
onChange={e => onChange(key, e.target.value || null)}
className="font-mono"
/>
)}
{type === 'date' && (
<Input
{...commonProps}
type="date"
onChange={e => onChange(key, e.target.value || null)}
/>
)}
{type === 'text' && (
<Input
{...commonProps}
type="text"
onChange={e => onChange(key, e.target.value || null)}
/>
)}
{def.hint && <div className="text-[11px] text-slate-400">{def.hint}</div>}
</div>
)
})}
</div>
)
}
export function DynamicFormPreviewError({ error }: { error: string }) {
return (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>{error}</div>
</div>
)
}