@fayz-ai/plugin-forms 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CustomFormsContext.d.ts +15 -0
- package/dist/CustomFormsContext.d.ts.map +1 -0
- package/dist/FormBuilder-4VKYVZAC.cjs +619 -0
- package/dist/FormBuilder-4VKYVZAC.cjs.map +1 -0
- package/dist/FormBuilder-KDANEL6Z.js +613 -0
- package/dist/FormBuilder-KDANEL6Z.js.map +1 -0
- package/dist/chunk-32I43T37.js +198 -0
- package/dist/chunk-32I43T37.js.map +1 -0
- package/dist/chunk-XLUULIVL.cjs +200 -0
- package/dist/chunk-XLUULIVL.cjs.map +1 -0
- package/dist/components/AddDocumentDropdown.d.ts +9 -0
- package/dist/components/AddDocumentDropdown.d.ts.map +1 -0
- package/dist/components/BuilderCanvas.d.ts +12 -0
- package/dist/components/BuilderCanvas.d.ts.map +1 -0
- package/dist/components/BuilderField.d.ts +11 -0
- package/dist/components/BuilderField.d.ts.map +1 -0
- package/dist/components/DocumentFormDialog.d.ts +17 -0
- package/dist/components/DocumentFormDialog.d.ts.map +1 -0
- package/dist/components/DocumentList.d.ts +14 -0
- package/dist/components/DocumentList.d.ts.map +1 -0
- package/dist/components/FieldConfigDialog.d.ts +12 -0
- package/dist/components/FieldConfigDialog.d.ts.map +1 -0
- package/dist/components/FieldPalette.d.ts +10 -0
- package/dist/components/FieldPalette.d.ts.map +1 -0
- package/dist/components/FormBuilder.d.ts +14 -0
- package/dist/components/FormBuilder.d.ts.map +1 -0
- package/dist/components/FormRenderer.d.ts +11 -0
- package/dist/components/FormRenderer.d.ts.map +1 -0
- package/dist/components/FormViewer.d.ts +12 -0
- package/dist/components/FormViewer.d.ts.map +1 -0
- package/dist/components/PersonDocumentsWidget.d.ts +17 -0
- package/dist/components/PersonDocumentsWidget.d.ts.map +1 -0
- package/dist/components/TemplateSelector.d.ts +11 -0
- package/dist/components/TemplateSelector.d.ts.map +1 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +17 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/document-types.d.ts +25 -0
- package/dist/document-types.d.ts.map +1 -0
- package/dist/index.cjs +1612 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1605 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/store.d.ts +29 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CustomFormsSettingsTab.d.ts +14 -0
- package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -0
- package/dist/views/TemplateListView.d.ts +13 -0
- package/dist/views/TemplateListView.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/CustomFormsContext.tsx +28 -0
- package/src/components/AddDocumentDropdown.tsx +92 -0
- package/src/components/BuilderCanvas.tsx +87 -0
- package/src/components/BuilderField.tsx +81 -0
- package/src/components/DocumentFormDialog.tsx +134 -0
- package/src/components/DocumentList.tsx +250 -0
- package/src/components/FieldConfigDialog.tsx +203 -0
- package/src/components/FieldPalette.tsx +81 -0
- package/src/components/FormBuilder.tsx +345 -0
- package/src/components/FormRenderer.tsx +256 -0
- package/src/components/FormViewer.tsx +84 -0
- package/src/components/PersonDocumentsWidget.tsx +220 -0
- package/src/components/TemplateSelector.tsx +91 -0
- package/src/config.ts +39 -0
- package/src/data/index.ts +3 -0
- package/src/data/mock.ts +193 -0
- package/src/data/supabase.ts +405 -0
- package/src/data/types.ts +33 -0
- package/src/document-types.ts +51 -0
- package/src/index.ts +195 -0
- package/src/lib/tenant.ts +5 -0
- package/src/locales/en.ts +75 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +75 -0
- package/src/migrations/001_frm_base.sql +138 -0
- package/src/migrations/002_document_archetype.sql +135 -0
- package/src/store.ts +167 -0
- package/src/types.ts +203 -0
- package/src/views/CustomFormsSettingsTab.tsx +115 -0
- package/src/views/TemplateListView.tsx +163 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DndContext,
|
|
4
|
+
DragEndEvent,
|
|
5
|
+
DragOverlay,
|
|
6
|
+
DragStartEvent,
|
|
7
|
+
PointerSensor,
|
|
8
|
+
pointerWithin,
|
|
9
|
+
useSensor,
|
|
10
|
+
useSensors,
|
|
11
|
+
} from '@dnd-kit/core'
|
|
12
|
+
import { arrayMove } from '@dnd-kit/sortable'
|
|
13
|
+
import { snapCenterToCursor } from '@dnd-kit/modifiers'
|
|
14
|
+
import { ArrowLeft, Eye } from 'lucide-react'
|
|
15
|
+
import { Button, useSaveBar, toast } from '@fayz-ai/ui'
|
|
16
|
+
import { Input } from '@fayz-ai/ui'
|
|
17
|
+
import { Card, CardContent } from '@fayz-ai/ui'
|
|
18
|
+
import { Badge } from '@fayz-ai/ui'
|
|
19
|
+
import {
|
|
20
|
+
Select,
|
|
21
|
+
SelectContent,
|
|
22
|
+
SelectItem,
|
|
23
|
+
SelectTrigger,
|
|
24
|
+
SelectValue,
|
|
25
|
+
} from '@fayz-ai/ui'
|
|
26
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
27
|
+
import type { CustomFormsStore } from '../store'
|
|
28
|
+
import type { CustomFormsDataProvider } from '../data/types'
|
|
29
|
+
import type { CustomFormsConfig } from '../config'
|
|
30
|
+
import type { FormFieldDef, FormSchema, TemplateCategory } from '../types'
|
|
31
|
+
import { FieldPalette, FIELD_TYPES, DEFAULT_COL_SPAN } from './FieldPalette'
|
|
32
|
+
import { BuilderCanvas } from './BuilderCanvas'
|
|
33
|
+
import { FieldConfigDrawer } from './FieldConfigDialog'
|
|
34
|
+
import { createPortal } from 'react-dom'
|
|
35
|
+
import { FormRenderer } from './FormRenderer'
|
|
36
|
+
|
|
37
|
+
const TEMPLATE_CATEGORIES: TemplateCategory[] = ['anamnesis', 'evolution', 'report', 'contract', 'general']
|
|
38
|
+
|
|
39
|
+
interface FormBuilderProps {
|
|
40
|
+
templateId?: string
|
|
41
|
+
store: CustomFormsStore
|
|
42
|
+
provider: CustomFormsDataProvider
|
|
43
|
+
config: CustomFormsConfig
|
|
44
|
+
onBack: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function FormBuilder({ templateId, store, provider, config, onBack }: FormBuilderProps) {
|
|
48
|
+
const t = useTranslation()
|
|
49
|
+
|
|
50
|
+
const [name, setName] = useState('')
|
|
51
|
+
const [description, setDescription] = useState('')
|
|
52
|
+
const [category, setCategory] = useState<TemplateCategory>('general')
|
|
53
|
+
const [fields, setFields] = useState<FormFieldDef[]>([])
|
|
54
|
+
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null)
|
|
55
|
+
const [saving, setSaving] = useState(false)
|
|
56
|
+
const [showPreview, setShowPreview] = useState(false)
|
|
57
|
+
const [previewData, setPreviewData] = useState<Record<string, unknown>>({})
|
|
58
|
+
const [draggingFieldType, setDraggingFieldType] = useState<import('../types').FormFieldType | null>(null)
|
|
59
|
+
const [templateName, setTemplateName] = useState<string | null>(null)
|
|
60
|
+
|
|
61
|
+
// Load existing template
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!templateId) return
|
|
64
|
+
let cancelled = false
|
|
65
|
+
provider.getTemplateById(templateId).then((tpl) => {
|
|
66
|
+
if (cancelled || !tpl) return
|
|
67
|
+
setName(tpl.name)
|
|
68
|
+
setTemplateName(tpl.name)
|
|
69
|
+
setDescription(tpl.description ?? '')
|
|
70
|
+
setCategory(tpl.category)
|
|
71
|
+
setFields(tpl.schema.fields)
|
|
72
|
+
})
|
|
73
|
+
return () => { cancelled = true }
|
|
74
|
+
}, [templateId, provider])
|
|
75
|
+
|
|
76
|
+
const sensors = useSensors(
|
|
77
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
81
|
+
const data = event.active.data.current
|
|
82
|
+
if (data?.source === 'palette') {
|
|
83
|
+
setDraggingFieldType(data.fieldType)
|
|
84
|
+
}
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
88
|
+
setDraggingFieldType(null)
|
|
89
|
+
const { active, over } = event
|
|
90
|
+
|
|
91
|
+
// Dropped from palette onto canvas
|
|
92
|
+
if (active.data.current?.source === 'palette' && over) {
|
|
93
|
+
const fieldType = active.data.current.fieldType
|
|
94
|
+
const typeDef = FIELD_TYPES.find((ft) => ft.type === fieldType)
|
|
95
|
+
if (!typeDef) return
|
|
96
|
+
|
|
97
|
+
const newField: FormFieldDef = {
|
|
98
|
+
id: crypto.randomUUID(),
|
|
99
|
+
type: fieldType,
|
|
100
|
+
label: t(`customForms.fieldType.${fieldType}`),
|
|
101
|
+
col: 0,
|
|
102
|
+
row: fields.length,
|
|
103
|
+
colSpan: DEFAULT_COL_SPAN[fieldType] ?? 12,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (['select', 'radio', 'tags'].includes(fieldType)) {
|
|
107
|
+
newField.options = [
|
|
108
|
+
{ label: 'Option 1', value: 'option_1' },
|
|
109
|
+
{ label: 'Option 2', value: 'option_2' },
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setFields((prev) => [...prev, newField])
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reorder within canvas
|
|
118
|
+
if (active.id !== over?.id && over) {
|
|
119
|
+
setFields((prev) => {
|
|
120
|
+
const oldIndex = prev.findIndex((f) => f.id === active.id)
|
|
121
|
+
const newIndex = prev.findIndex((f) => f.id === over.id)
|
|
122
|
+
if (oldIndex === -1 || newIndex === -1) return prev
|
|
123
|
+
const reordered = arrayMove(prev, oldIndex, newIndex)
|
|
124
|
+
return reordered.map((f, i) => ({ ...f, row: i }))
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}, [fields, t])
|
|
128
|
+
|
|
129
|
+
const handleSelectField = useCallback((field: FormFieldDef) => {
|
|
130
|
+
setSelectedFieldId(field.id)
|
|
131
|
+
}, [])
|
|
132
|
+
|
|
133
|
+
const handleRemoveField = useCallback((fieldId: string) => {
|
|
134
|
+
setFields((prev) => prev.filter((f) => f.id !== fieldId).map((f, i) => ({ ...f, row: i })))
|
|
135
|
+
if (selectedFieldId === fieldId) setSelectedFieldId(null)
|
|
136
|
+
}, [selectedFieldId])
|
|
137
|
+
|
|
138
|
+
const handleFieldUpdate = useCallback((updated: FormFieldDef) => {
|
|
139
|
+
setFields((prev) => prev.map((f) => (f.id === updated.id ? updated : f)))
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const handleFieldConfigSave = useCallback((updated: FormFieldDef) => {
|
|
143
|
+
handleFieldUpdate(updated)
|
|
144
|
+
setSelectedFieldId(null)
|
|
145
|
+
}, [handleFieldUpdate])
|
|
146
|
+
|
|
147
|
+
const handleSave = useCallback(async () => {
|
|
148
|
+
if (!name.trim()) { toast.error(t('common.formIncomplete')); return }
|
|
149
|
+
setSaving(true)
|
|
150
|
+
try {
|
|
151
|
+
const schema: FormSchema = { fields, layout: { columns: 12 } }
|
|
152
|
+
|
|
153
|
+
if (templateId) {
|
|
154
|
+
await store.getState().updateTemplate(templateId, { name, description, category, schema })
|
|
155
|
+
} else {
|
|
156
|
+
await store.getState().createTemplate({ name, description, category, schema })
|
|
157
|
+
}
|
|
158
|
+
onBack()
|
|
159
|
+
} finally {
|
|
160
|
+
setSaving(false)
|
|
161
|
+
}
|
|
162
|
+
}, [templateId, name, description, category, fields, store, onBack])
|
|
163
|
+
|
|
164
|
+
// Dirty detection — snapshot editable fields once async load settles
|
|
165
|
+
const currentFields = { name, description, category, fields }
|
|
166
|
+
const snapshot = React.useRef<string | null>(null)
|
|
167
|
+
const loaded = !templateId || templateName !== null
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (loaded && snapshot.current === null) snapshot.current = JSON.stringify(currentFields)
|
|
170
|
+
}, [loaded])
|
|
171
|
+
const dirty = snapshot.current !== null && JSON.stringify(currentFields) !== snapshot.current
|
|
172
|
+
|
|
173
|
+
useSaveBar({
|
|
174
|
+
dirty,
|
|
175
|
+
saving,
|
|
176
|
+
onSave: () => { void handleSave() },
|
|
177
|
+
onDiscard: () => onBack(),
|
|
178
|
+
saveLabel: t('customForms.builder.save'),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const selectedField = selectedFieldId
|
|
182
|
+
? fields.find((f) => f.id === selectedFieldId) ?? null
|
|
183
|
+
: null
|
|
184
|
+
|
|
185
|
+
const isNew = !templateId
|
|
186
|
+
const breadcrumbLabel = isNew
|
|
187
|
+
? t('customForms.newTemplate')
|
|
188
|
+
: templateName ?? t('customForms.editTemplate')
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="space-y-4">
|
|
192
|
+
{/* Breadcrumb */}
|
|
193
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
194
|
+
<button
|
|
195
|
+
onClick={onBack}
|
|
196
|
+
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
197
|
+
>
|
|
198
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
199
|
+
{t('customForms.templates')}
|
|
200
|
+
</button>
|
|
201
|
+
<span className="text-muted-foreground">/</span>
|
|
202
|
+
<span className="font-medium">{breadcrumbLabel}</span>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Form meta — card layout matching CRUD form */}
|
|
206
|
+
<Card>
|
|
207
|
+
<CardContent className="p-5 space-y-4">
|
|
208
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
209
|
+
<div className="space-y-1.5">
|
|
210
|
+
<label className="text-sm font-medium">
|
|
211
|
+
{t('customForms.templateName')} <span className="text-destructive">*</span>
|
|
212
|
+
</label>
|
|
213
|
+
<Input
|
|
214
|
+
value={name}
|
|
215
|
+
onChange={(e) => setName(e.target.value)}
|
|
216
|
+
placeholder={t('customForms.templateName')}
|
|
217
|
+
className="h-9 text-sm"
|
|
218
|
+
autoFocus={isNew}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="space-y-1.5">
|
|
222
|
+
<label className="text-sm font-medium">{t('customForms.templateCategory')}</label>
|
|
223
|
+
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
|
|
224
|
+
<SelectTrigger className="h-9 text-sm">
|
|
225
|
+
<SelectValue />
|
|
226
|
+
</SelectTrigger>
|
|
227
|
+
<SelectContent>
|
|
228
|
+
{TEMPLATE_CATEGORIES.map((cat) => (
|
|
229
|
+
<SelectItem key={cat} value={cat}>
|
|
230
|
+
{t(`customForms.category.${cat}`)}
|
|
231
|
+
</SelectItem>
|
|
232
|
+
))}
|
|
233
|
+
</SelectContent>
|
|
234
|
+
</Select>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="space-y-1.5">
|
|
238
|
+
<label className="text-sm font-medium">{t('customForms.templateDescription')}</label>
|
|
239
|
+
<Input
|
|
240
|
+
value={description}
|
|
241
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
242
|
+
placeholder={t('customForms.templateDescription')}
|
|
243
|
+
className="h-9 text-sm"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
</CardContent>
|
|
247
|
+
</Card>
|
|
248
|
+
|
|
249
|
+
{/* Builder section header */}
|
|
250
|
+
<div className="flex items-center justify-between">
|
|
251
|
+
<div className="flex items-center gap-2">
|
|
252
|
+
<h3 className="text-sm font-semibold">{t('customForms.builder.fields')}</h3>
|
|
253
|
+
<Badge variant="secondary" className="text-[10px]">
|
|
254
|
+
{t('customForms.builder.fieldCount', { count: String(fields.length) })}
|
|
255
|
+
</Badge>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="flex items-center gap-2">
|
|
258
|
+
<Button
|
|
259
|
+
variant="outline"
|
|
260
|
+
size="sm"
|
|
261
|
+
className="h-9"
|
|
262
|
+
disabled={fields.length === 0}
|
|
263
|
+
onClick={() => { setPreviewData({}); setShowPreview(true) }}
|
|
264
|
+
>
|
|
265
|
+
<Eye className="h-3.5 w-3.5 mr-1.5" />
|
|
266
|
+
Preview
|
|
267
|
+
</Button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Builder body — drag palette + canvas */}
|
|
272
|
+
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
273
|
+
<div className="flex gap-4">
|
|
274
|
+
<FieldPalette />
|
|
275
|
+
<BuilderCanvas
|
|
276
|
+
fields={fields}
|
|
277
|
+
selectedFieldId={selectedFieldId}
|
|
278
|
+
onSelectField={handleSelectField}
|
|
279
|
+
onRemoveField={handleRemoveField}
|
|
280
|
+
draggingFieldType={draggingFieldType}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}>
|
|
285
|
+
{draggingFieldType ? (
|
|
286
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-card shadow-xl text-sm font-medium opacity-90 pointer-events-none">
|
|
287
|
+
{(() => {
|
|
288
|
+
const def = FIELD_TYPES.find((ft) => ft.type === draggingFieldType)
|
|
289
|
+
if (!def) return null
|
|
290
|
+
const Icon = def.icon
|
|
291
|
+
return (
|
|
292
|
+
<>
|
|
293
|
+
<Icon className="h-4 w-4 text-primary" />
|
|
294
|
+
{t(`customForms.fieldType.${draggingFieldType}`)}
|
|
295
|
+
</>
|
|
296
|
+
)
|
|
297
|
+
})()}
|
|
298
|
+
</div>
|
|
299
|
+
) : null}
|
|
300
|
+
</DragOverlay>
|
|
301
|
+
</DndContext>
|
|
302
|
+
|
|
303
|
+
{/* Field config drawer (right side) */}
|
|
304
|
+
<FieldConfigDrawer
|
|
305
|
+
field={selectedField}
|
|
306
|
+
onSave={handleFieldConfigSave}
|
|
307
|
+
onUpdate={handleFieldUpdate}
|
|
308
|
+
onClose={() => setSelectedFieldId(null)}
|
|
309
|
+
/>
|
|
310
|
+
|
|
311
|
+
{/* Preview — A4 paper overlay (portaled to body) */}
|
|
312
|
+
{showPreview && createPortal(
|
|
313
|
+
<div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/60 overflow-y-auto py-10 px-4" onClick={() => setShowPreview(false)}>
|
|
314
|
+
<div
|
|
315
|
+
className="relative w-full max-w-3xl bg-white rounded-modal shadow-lg"
|
|
316
|
+
style={{ minHeight: '80vh' }}
|
|
317
|
+
onClick={(e) => e.stopPropagation()}
|
|
318
|
+
>
|
|
319
|
+
{/* Close button — kept on white paper background; literal grays are intentional print look */}
|
|
320
|
+
<button
|
|
321
|
+
onClick={() => setShowPreview(false)}
|
|
322
|
+
className="absolute top-3 right-3 z-10 flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors"
|
|
323
|
+
>
|
|
324
|
+
<span className="text-lg">✕</span>
|
|
325
|
+
</button>
|
|
326
|
+
|
|
327
|
+
{/* Paper content — printed A4 simulation; literal black-on-white is the point */}
|
|
328
|
+
<div className="px-10 py-8 sm:px-14 sm:py-10 text-neutral-900">
|
|
329
|
+
<h1 className="text-2xl font-bold mb-1">{name || t('customForms.newTemplate')}</h1>
|
|
330
|
+
{description && <p className="text-sm text-neutral-500 mb-8">{description}</p>}
|
|
331
|
+
{!description && <div className="mb-8" />}
|
|
332
|
+
|
|
333
|
+
<FormRenderer
|
|
334
|
+
schema={{ fields, layout: { columns: 12 } }}
|
|
335
|
+
data={previewData}
|
|
336
|
+
onChange={setPreviewData}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>,
|
|
341
|
+
document.body,
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import React, { useCallback } from 'react'
|
|
2
|
+
import { Input } from '@fayz-ai/ui'
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from '@fayz-ai/ui'
|
|
10
|
+
import { Badge } from '@fayz-ai/ui'
|
|
11
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
12
|
+
import type { FormFieldDef, FormSchema } from '../types'
|
|
13
|
+
|
|
14
|
+
interface FormRendererProps {
|
|
15
|
+
schema: FormSchema
|
|
16
|
+
data: Record<string, unknown>
|
|
17
|
+
onChange: (data: Record<string, unknown>) => void
|
|
18
|
+
readOnly?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function FieldRenderer({
|
|
22
|
+
field,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
readOnly,
|
|
26
|
+
}: {
|
|
27
|
+
field: FormFieldDef
|
|
28
|
+
value: unknown
|
|
29
|
+
onChange: (value: unknown) => void
|
|
30
|
+
readOnly?: boolean
|
|
31
|
+
}) {
|
|
32
|
+
const t = useTranslation()
|
|
33
|
+
|
|
34
|
+
switch (field.type) {
|
|
35
|
+
case 'title':
|
|
36
|
+
return (
|
|
37
|
+
<h3 className="text-base font-semibold pt-2 pb-1 border-b">
|
|
38
|
+
{field.label}
|
|
39
|
+
</h3>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
case 'text':
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-1.5">
|
|
45
|
+
<label className="text-xs font-medium block">
|
|
46
|
+
{field.label}
|
|
47
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
48
|
+
</label>
|
|
49
|
+
<Input
|
|
50
|
+
value={(value as string) ?? ''}
|
|
51
|
+
onChange={(e) => onChange(e.target.value)}
|
|
52
|
+
placeholder={field.placeholder}
|
|
53
|
+
className="h-9 text-sm"
|
|
54
|
+
readOnly={readOnly}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
case 'memo':
|
|
60
|
+
case 'richtext':
|
|
61
|
+
return (
|
|
62
|
+
<div className="space-y-1.5">
|
|
63
|
+
<label className="text-xs font-medium block">
|
|
64
|
+
{field.label}
|
|
65
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
66
|
+
</label>
|
|
67
|
+
<textarea
|
|
68
|
+
value={(value as string) ?? ''}
|
|
69
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
|
70
|
+
placeholder={field.placeholder}
|
|
71
|
+
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[100px]"
|
|
72
|
+
readOnly={readOnly}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
case 'date':
|
|
78
|
+
return (
|
|
79
|
+
<div className="space-y-1.5">
|
|
80
|
+
<label className="text-xs font-medium block">
|
|
81
|
+
{field.label}
|
|
82
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
83
|
+
</label>
|
|
84
|
+
<Input
|
|
85
|
+
type="date"
|
|
86
|
+
value={(value as string) ?? ''}
|
|
87
|
+
onChange={(e) => onChange(e.target.value)}
|
|
88
|
+
className="h-9 text-sm"
|
|
89
|
+
readOnly={readOnly}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
case 'select':
|
|
95
|
+
return (
|
|
96
|
+
<div className="space-y-1.5">
|
|
97
|
+
<label className="text-xs font-medium block">
|
|
98
|
+
{field.label}
|
|
99
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
100
|
+
</label>
|
|
101
|
+
{readOnly ? (
|
|
102
|
+
<p className="text-sm py-1">{(value as string) ?? '-'}</p>
|
|
103
|
+
) : (
|
|
104
|
+
<Select value={(value as string) ?? ''} onValueChange={onChange}>
|
|
105
|
+
<SelectTrigger className="h-9 text-sm">
|
|
106
|
+
<SelectValue placeholder={field.placeholder ?? '...'} />
|
|
107
|
+
</SelectTrigger>
|
|
108
|
+
<SelectContent>
|
|
109
|
+
{(field.options ?? []).map((opt) => (
|
|
110
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
111
|
+
{opt.label}
|
|
112
|
+
</SelectItem>
|
|
113
|
+
))}
|
|
114
|
+
</SelectContent>
|
|
115
|
+
</Select>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
case 'radio':
|
|
121
|
+
return (
|
|
122
|
+
<div className="space-y-1.5">
|
|
123
|
+
<label className="text-xs font-medium block">
|
|
124
|
+
{field.label}
|
|
125
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
126
|
+
</label>
|
|
127
|
+
<div className="space-y-1">
|
|
128
|
+
{(field.options ?? []).map((opt) => (
|
|
129
|
+
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
|
130
|
+
<input
|
|
131
|
+
type="radio"
|
|
132
|
+
name={field.id}
|
|
133
|
+
value={opt.value}
|
|
134
|
+
checked={value === opt.value}
|
|
135
|
+
onChange={() => onChange(opt.value)}
|
|
136
|
+
disabled={readOnly}
|
|
137
|
+
className="text-primary"
|
|
138
|
+
/>
|
|
139
|
+
<span className="text-sm">{opt.label}</span>
|
|
140
|
+
</label>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
case 'tags': {
|
|
147
|
+
const selectedTags = Array.isArray(value) ? (value as string[]) : []
|
|
148
|
+
return (
|
|
149
|
+
<div className="space-y-1.5">
|
|
150
|
+
<label className="text-xs font-medium block">
|
|
151
|
+
{field.label}
|
|
152
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
153
|
+
</label>
|
|
154
|
+
<div className="flex flex-wrap gap-1.5">
|
|
155
|
+
{(field.options ?? []).map((opt) => {
|
|
156
|
+
const active = selectedTags.includes(opt.value)
|
|
157
|
+
return (
|
|
158
|
+
<Badge
|
|
159
|
+
key={opt.value}
|
|
160
|
+
variant={active ? 'default' : 'outline'}
|
|
161
|
+
className={`cursor-pointer text-xs ${readOnly ? 'pointer-events-none' : ''}`}
|
|
162
|
+
onClick={() => {
|
|
163
|
+
if (readOnly) return
|
|
164
|
+
onChange(
|
|
165
|
+
active
|
|
166
|
+
? selectedTags.filter((t) => t !== opt.value)
|
|
167
|
+
: [...selectedTags, opt.value],
|
|
168
|
+
)
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{opt.label}
|
|
172
|
+
</Badge>
|
|
173
|
+
)
|
|
174
|
+
})}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'checkbox':
|
|
181
|
+
return (
|
|
182
|
+
<label className="flex items-center gap-2 cursor-pointer py-1">
|
|
183
|
+
<input
|
|
184
|
+
type="checkbox"
|
|
185
|
+
checked={!!value}
|
|
186
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
187
|
+
disabled={readOnly}
|
|
188
|
+
className="rounded border-input"
|
|
189
|
+
/>
|
|
190
|
+
<span className="text-sm font-medium">
|
|
191
|
+
{field.label}
|
|
192
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
193
|
+
</span>
|
|
194
|
+
</label>
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
case 'image':
|
|
198
|
+
case 'gallery':
|
|
199
|
+
return (
|
|
200
|
+
<div className="space-y-1.5">
|
|
201
|
+
<label className="text-xs font-medium block">
|
|
202
|
+
{field.label}
|
|
203
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
204
|
+
</label>
|
|
205
|
+
<div className="flex items-center justify-center rounded-lg border-2 border-dashed py-8 text-sm text-muted-foreground">
|
|
206
|
+
{field.type === 'gallery' ? 'Gallery upload' : 'Image upload'} — v2
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
case 'budget':
|
|
212
|
+
return (
|
|
213
|
+
<div className="space-y-1.5">
|
|
214
|
+
<label className="text-xs font-medium block">
|
|
215
|
+
{field.label}
|
|
216
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
217
|
+
</label>
|
|
218
|
+
<div className="flex items-center justify-center rounded-lg border-2 border-dashed py-8 text-sm text-muted-foreground">
|
|
219
|
+
Budget / line items — v2
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
default:
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function FormRenderer({ schema, data, onChange, readOnly }: FormRendererProps) {
|
|
230
|
+
const sortedFields = [...schema.fields].sort((a, b) => a.row - b.row || a.col - b.col)
|
|
231
|
+
|
|
232
|
+
const handleFieldChange = useCallback(
|
|
233
|
+
(fieldId: string, value: unknown) => {
|
|
234
|
+
onChange({ ...data, [fieldId]: value })
|
|
235
|
+
},
|
|
236
|
+
[data, onChange],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div
|
|
241
|
+
className="grid gap-x-4 gap-y-3"
|
|
242
|
+
style={{ gridTemplateColumns: 'repeat(12, 1fr)' }}
|
|
243
|
+
>
|
|
244
|
+
{sortedFields.map((field) => (
|
|
245
|
+
<div key={field.id} style={{ gridColumn: `span ${field.colSpan}` }}>
|
|
246
|
+
<FieldRenderer
|
|
247
|
+
field={field}
|
|
248
|
+
value={data[field.id]}
|
|
249
|
+
onChange={(v) => handleFieldChange(field.id, v)}
|
|
250
|
+
readOnly={readOnly}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { ArrowLeft, Pencil, Badge as BadgeIcon } from 'lucide-react'
|
|
3
|
+
import { Button } from '@fayz-ai/ui'
|
|
4
|
+
import { Badge } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import type { CustomFormsDataProvider } from '../data/types'
|
|
7
|
+
import type { FormDocument, FormTemplate } from '../types'
|
|
8
|
+
import { FormRenderer } from './FormRenderer'
|
|
9
|
+
|
|
10
|
+
interface FormViewerProps {
|
|
11
|
+
document: FormDocument
|
|
12
|
+
provider: CustomFormsDataProvider
|
|
13
|
+
onBack: () => void
|
|
14
|
+
onEdit?: (doc: FormDocument) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
18
|
+
draft: 'bg-warning-soft text-warning-soft-foreground',
|
|
19
|
+
completed: 'bg-success-soft text-success-soft-foreground',
|
|
20
|
+
signed: 'bg-info-soft text-info-soft-foreground',
|
|
21
|
+
archived: 'bg-muted text-muted-foreground',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function FormViewer({ document: doc, provider, onBack, onEdit }: FormViewerProps) {
|
|
25
|
+
const t = useTranslation()
|
|
26
|
+
const [template, setTemplate] = useState<FormTemplate | null>(null)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!doc.templateId) return
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
provider.getTemplateById(doc.templateId).then(setTemplate)
|
|
32
|
+
}, 50)
|
|
33
|
+
return () => clearTimeout(timer)
|
|
34
|
+
}, [doc.templateId, provider])
|
|
35
|
+
|
|
36
|
+
if (!template) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex items-center justify-center py-12">
|
|
39
|
+
<div className="h-5 w-5 border-2 border-primary border-t-transparent animate-spin rounded-full" />
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="space-y-4">
|
|
46
|
+
{/* Header */}
|
|
47
|
+
<div className="flex items-center gap-3">
|
|
48
|
+
<button
|
|
49
|
+
onClick={onBack}
|
|
50
|
+
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
51
|
+
>
|
|
52
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
53
|
+
</button>
|
|
54
|
+
<div className="flex-1 min-w-0">
|
|
55
|
+
<h3 className="font-semibold text-sm truncate">
|
|
56
|
+
{doc.title ?? template.name}
|
|
57
|
+
</h3>
|
|
58
|
+
<p className="text-xs text-muted-foreground">
|
|
59
|
+
{new Date(doc.createdAt).toLocaleDateString()}
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
<Badge variant="secondary" className={`text-[10px] ${STATUS_COLORS[doc.status] ?? ''}`}>
|
|
63
|
+
{t(`customForms.status.${doc.status}`)}
|
|
64
|
+
</Badge>
|
|
65
|
+
{onEdit && doc.status === 'draft' && (
|
|
66
|
+
<Button variant="outline" size="sm" className="h-8" onClick={() => onEdit(doc)}>
|
|
67
|
+
<Pencil className="h-3 w-3 mr-1" />
|
|
68
|
+
{t('common.edit')}
|
|
69
|
+
</Button>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Form content (read-only) */}
|
|
74
|
+
<div className="rounded-xl border p-4">
|
|
75
|
+
<FormRenderer
|
|
76
|
+
schema={template.schema}
|
|
77
|
+
data={doc.data}
|
|
78
|
+
onChange={() => {}}
|
|
79
|
+
readOnly
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|