[CLAUDE] App+Infra+FE-Admin: DynamicForm + .doc/.xls auto-convert on upload
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m17s
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:
150
fe-admin/src/components/DynamicForm.tsx
Normal file
150
fe-admin/src/components/DynamicForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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/gửi không chỉnh sửa đượ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/gửi.
|
||||
</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 có FieldSpec — dùng JSON tự do. Thêm FieldSpec vào template để dùng form builder.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Dialog>
|
||||
|
||||
{/* Upload / Edit dialog */}
|
||||
|
||||
Reference in New Issue
Block a user