@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.
Files changed (99) hide show
  1. package/dist/CustomFormsContext.d.ts +15 -0
  2. package/dist/CustomFormsContext.d.ts.map +1 -0
  3. package/dist/FormBuilder-4VKYVZAC.cjs +619 -0
  4. package/dist/FormBuilder-4VKYVZAC.cjs.map +1 -0
  5. package/dist/FormBuilder-KDANEL6Z.js +613 -0
  6. package/dist/FormBuilder-KDANEL6Z.js.map +1 -0
  7. package/dist/chunk-32I43T37.js +198 -0
  8. package/dist/chunk-32I43T37.js.map +1 -0
  9. package/dist/chunk-XLUULIVL.cjs +200 -0
  10. package/dist/chunk-XLUULIVL.cjs.map +1 -0
  11. package/dist/components/AddDocumentDropdown.d.ts +9 -0
  12. package/dist/components/AddDocumentDropdown.d.ts.map +1 -0
  13. package/dist/components/BuilderCanvas.d.ts +12 -0
  14. package/dist/components/BuilderCanvas.d.ts.map +1 -0
  15. package/dist/components/BuilderField.d.ts +11 -0
  16. package/dist/components/BuilderField.d.ts.map +1 -0
  17. package/dist/components/DocumentFormDialog.d.ts +17 -0
  18. package/dist/components/DocumentFormDialog.d.ts.map +1 -0
  19. package/dist/components/DocumentList.d.ts +14 -0
  20. package/dist/components/DocumentList.d.ts.map +1 -0
  21. package/dist/components/FieldConfigDialog.d.ts +12 -0
  22. package/dist/components/FieldConfigDialog.d.ts.map +1 -0
  23. package/dist/components/FieldPalette.d.ts +10 -0
  24. package/dist/components/FieldPalette.d.ts.map +1 -0
  25. package/dist/components/FormBuilder.d.ts +14 -0
  26. package/dist/components/FormBuilder.d.ts.map +1 -0
  27. package/dist/components/FormRenderer.d.ts +11 -0
  28. package/dist/components/FormRenderer.d.ts.map +1 -0
  29. package/dist/components/FormViewer.d.ts +12 -0
  30. package/dist/components/FormViewer.d.ts.map +1 -0
  31. package/dist/components/PersonDocumentsWidget.d.ts +17 -0
  32. package/dist/components/PersonDocumentsWidget.d.ts.map +1 -0
  33. package/dist/components/TemplateSelector.d.ts +11 -0
  34. package/dist/components/TemplateSelector.d.ts.map +1 -0
  35. package/dist/config.d.ts +23 -0
  36. package/dist/config.d.ts.map +1 -0
  37. package/dist/data/index.d.ts +4 -0
  38. package/dist/data/index.d.ts.map +1 -0
  39. package/dist/data/mock.d.ts +3 -0
  40. package/dist/data/mock.d.ts.map +1 -0
  41. package/dist/data/supabase.d.ts +3 -0
  42. package/dist/data/supabase.d.ts.map +1 -0
  43. package/dist/data/types.d.ts +17 -0
  44. package/dist/data/types.d.ts.map +1 -0
  45. package/dist/document-types.d.ts +25 -0
  46. package/dist/document-types.d.ts.map +1 -0
  47. package/dist/index.cjs +1612 -0
  48. package/dist/index.cjs.map +1 -0
  49. package/dist/index.d.ts +6 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +1605 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/lib/tenant.d.ts +3 -0
  54. package/dist/lib/tenant.d.ts.map +1 -0
  55. package/dist/locales/en.d.ts +2 -0
  56. package/dist/locales/en.d.ts.map +1 -0
  57. package/dist/locales/index.d.ts +2 -0
  58. package/dist/locales/index.d.ts.map +1 -0
  59. package/dist/locales/pt-BR.d.ts +2 -0
  60. package/dist/locales/pt-BR.d.ts.map +1 -0
  61. package/dist/store.d.ts +29 -0
  62. package/dist/store.d.ts.map +1 -0
  63. package/dist/types.d.ts +150 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/views/CustomFormsSettingsTab.d.ts +14 -0
  66. package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -0
  67. package/dist/views/TemplateListView.d.ts +13 -0
  68. package/dist/views/TemplateListView.d.ts.map +1 -0
  69. package/package.json +57 -0
  70. package/src/CustomFormsContext.tsx +28 -0
  71. package/src/components/AddDocumentDropdown.tsx +92 -0
  72. package/src/components/BuilderCanvas.tsx +87 -0
  73. package/src/components/BuilderField.tsx +81 -0
  74. package/src/components/DocumentFormDialog.tsx +134 -0
  75. package/src/components/DocumentList.tsx +250 -0
  76. package/src/components/FieldConfigDialog.tsx +203 -0
  77. package/src/components/FieldPalette.tsx +81 -0
  78. package/src/components/FormBuilder.tsx +345 -0
  79. package/src/components/FormRenderer.tsx +256 -0
  80. package/src/components/FormViewer.tsx +84 -0
  81. package/src/components/PersonDocumentsWidget.tsx +220 -0
  82. package/src/components/TemplateSelector.tsx +91 -0
  83. package/src/config.ts +39 -0
  84. package/src/data/index.ts +3 -0
  85. package/src/data/mock.ts +193 -0
  86. package/src/data/supabase.ts +405 -0
  87. package/src/data/types.ts +33 -0
  88. package/src/document-types.ts +51 -0
  89. package/src/index.ts +195 -0
  90. package/src/lib/tenant.ts +5 -0
  91. package/src/locales/en.ts +75 -0
  92. package/src/locales/index.ts +7 -0
  93. package/src/locales/pt-BR.ts +75 -0
  94. package/src/migrations/001_frm_base.sql +138 -0
  95. package/src/migrations/002_document_archetype.sql +135 -0
  96. package/src/store.ts +167 -0
  97. package/src/types.ts +203 -0
  98. package/src/views/CustomFormsSettingsTab.tsx +115 -0
  99. 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
+ }