@fayz-ai/plugin-forms 0.9.0 → 0.9.2

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 (82) hide show
  1. package/README.md +3 -3
  2. package/dist/{FormBuilder-7SWZQKFN.js → FormBuilder-YHJHF6HZ.js} +4 -4
  3. package/dist/{FormBuilder-7SWZQKFN.js.map → FormBuilder-YHJHF6HZ.js.map} +1 -1
  4. package/dist/{chunk-W2ZNJGQC.js → chunk-RBLXIKWB.js} +221 -8
  5. package/dist/chunk-RBLXIKWB.js.map +1 -0
  6. package/dist/components/AddDocumentDropdown.d.ts +1 -1
  7. package/dist/components/AddDocumentDropdown.d.ts.map +1 -1
  8. package/dist/components/DocumentFormDialog.d.ts.map +1 -1
  9. package/dist/components/DocumentSkeleton.d.ts +21 -0
  10. package/dist/components/DocumentSkeleton.d.ts.map +1 -0
  11. package/dist/components/FormRenderer.d.ts.map +1 -1
  12. package/dist/components/FormViewer.d.ts.map +1 -1
  13. package/dist/{CustomFormsContext.d.ts → context.d.ts} +1 -1
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/data/mock.d.ts.map +1 -1
  16. package/dist/data/supabase.d.ts.map +1 -1
  17. package/dist/data/types.d.ts +4 -1
  18. package/dist/data/types.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +174 -17
  22. package/dist/index.js.map +1 -1
  23. package/dist/lib/agent-tools.d.ts.map +1 -0
  24. package/dist/lib/document-types.d.ts.map +1 -0
  25. package/dist/lib/html-text.d.ts +21 -0
  26. package/dist/lib/html-text.d.ts.map +1 -0
  27. package/dist/lib/print.d.ts.map +1 -1
  28. package/dist/lib/timeline-source.d.ts +10 -0
  29. package/dist/lib/timeline-source.d.ts.map +1 -0
  30. package/dist/locales/en.d.ts.map +1 -1
  31. package/dist/locales/pt-BR.d.ts.map +1 -1
  32. package/dist/migrations/index.d.ts +1 -0
  33. package/dist/migrations/index.d.ts.map +1 -1
  34. package/dist/store.d.ts +5 -1
  35. package/dist/store.d.ts.map +1 -1
  36. package/dist/types.d.ts +13 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/views/TemplateListView.d.ts.map +1 -1
  39. package/package.json +5 -7
  40. package/dist/CustomFormsContext.d.ts.map +0 -1
  41. package/dist/agent-tools.d.ts.map +0 -1
  42. package/dist/chunk-W2ZNJGQC.js.map +0 -1
  43. package/dist/document-types.d.ts.map +0 -1
  44. package/src/CustomFormsContext.tsx +0 -28
  45. package/src/agent-tools.ts +0 -73
  46. package/src/components/AddDocumentDropdown.tsx +0 -92
  47. package/src/components/BuilderCanvas.tsx +0 -87
  48. package/src/components/BuilderField.tsx +0 -81
  49. package/src/components/DocumentFormDialog.tsx +0 -155
  50. package/src/components/DocumentList.tsx +0 -254
  51. package/src/components/FieldConfigDialog.tsx +0 -243
  52. package/src/components/FieldPalette.tsx +0 -81
  53. package/src/components/FormBuilder.tsx +0 -427
  54. package/src/components/FormRenderer.tsx +0 -256
  55. package/src/components/FormViewer.tsx +0 -94
  56. package/src/components/PersonDocumentsWidget.tsx +0 -229
  57. package/src/components/TemplateSelector.tsx +0 -91
  58. package/src/config.ts +0 -43
  59. package/src/data/index.ts +0 -3
  60. package/src/data/mock.ts +0 -193
  61. package/src/data/supabase.ts +0 -408
  62. package/src/data/tables.ts +0 -7
  63. package/src/data/types.ts +0 -33
  64. package/src/document-types.ts +0 -51
  65. package/src/index.ts +0 -234
  66. package/src/lib/person-fields.ts +0 -67
  67. package/src/lib/print.ts +0 -207
  68. package/src/lib/tenant.ts +0 -9
  69. package/src/locales/en.ts +0 -95
  70. package/src/locales/index.ts +0 -7
  71. package/src/locales/pt-BR.ts +0 -95
  72. package/src/migrations/000_plg_rename.sql +0 -21
  73. package/src/migrations/001_frm_base.sql +0 -174
  74. package/src/migrations/002_document_archetype.sql +0 -223
  75. package/src/migrations/003_agent_rpcs.sql +0 -174
  76. package/src/migrations/index.ts +0 -610
  77. package/src/store.ts +0 -167
  78. package/src/types.ts +0 -217
  79. package/src/views/CustomFormsSettingsTab.tsx +0 -105
  80. package/src/views/TemplateListView.tsx +0 -174
  81. /package/dist/{agent-tools.d.ts → lib/agent-tools.d.ts} +0 -0
  82. /package/dist/{document-types.d.ts → lib/document-types.d.ts} +0 -0
@@ -1,427 +0,0 @@
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, Pencil, Printer } 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 { useLimitGuard, invalidateLimit, useAgentSurface, type AgentSurfaceContribution } from '@fayz-ai/saas'
28
- import type { CustomFormsStore } from '../store'
29
- import type { CustomFormsDataProvider } from '../data/types'
30
- import type { CustomFormsConfig } from '../config'
31
- import type { FormFieldDef, FormSchema, TemplateCategory } from '../types'
32
- import { FieldPalette, FIELD_TYPES, DEFAULT_COL_SPAN } from './FieldPalette'
33
- import { BuilderCanvas } from './BuilderCanvas'
34
- import { FieldConfigDrawer } from './FieldConfigDialog'
35
- import { createPortal } from 'react-dom'
36
- import { FormRenderer } from './FormRenderer'
37
- import { buildPrintHtml } from '../lib/print'
38
- import { FORM_BUILDER_INSTRUCTIONS } from '../agent-tools'
39
-
40
- const TEMPLATE_CATEGORIES: TemplateCategory[] = ['anamnesis', 'evolution', 'report', 'contract', 'general']
41
-
42
- interface FormBuilderProps {
43
- templateId?: string
44
- store: CustomFormsStore
45
- provider: CustomFormsDataProvider
46
- config: CustomFormsConfig
47
- onBack: () => void
48
- }
49
-
50
- export function FormBuilder({ templateId, store, provider, config, onBack }: FormBuilderProps) {
51
- const t = useTranslation()
52
- const guardTemplates = useLimitGuard('form_templates')
53
-
54
- const [name, setName] = useState('')
55
- const [description, setDescription] = useState('')
56
- const [category, setCategory] = useState<TemplateCategory>('general')
57
- const [fields, setFields] = useState<FormFieldDef[]>([])
58
- const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null)
59
- const [saving, setSaving] = useState(false)
60
- const [showPreview, setShowPreview] = useState(false)
61
- const [previewMode, setPreviewMode] = useState<'editor' | 'print'>('editor')
62
- const [previewData, setPreviewData] = useState<Record<string, unknown>>({})
63
- const [draggingFieldType, setDraggingFieldType] = useState<import('../types').FormFieldType | null>(null)
64
- const [templateName, setTemplateName] = useState<string | null>(null)
65
-
66
- // Load existing template
67
- useEffect(() => {
68
- if (!templateId) return
69
- let cancelled = false
70
- provider.getTemplateById(templateId).then((tpl) => {
71
- if (cancelled || !tpl) return
72
- setName(tpl.name)
73
- setTemplateName(tpl.name)
74
- setDescription(tpl.description ?? '')
75
- setCategory(tpl.category)
76
- setFields(tpl.schema.fields)
77
- })
78
- return () => { cancelled = true }
79
- }, [templateId, provider])
80
-
81
- const sensors = useSensors(
82
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
83
- )
84
-
85
- const handleDragStart = useCallback((event: DragStartEvent) => {
86
- const data = event.active.data.current
87
- if (data?.source === 'palette') {
88
- setDraggingFieldType(data.fieldType)
89
- }
90
- }, [])
91
-
92
- const handleDragEnd = useCallback((event: DragEndEvent) => {
93
- setDraggingFieldType(null)
94
- const { active, over } = event
95
-
96
- // Dropped from palette onto canvas
97
- if (active.data.current?.source === 'palette' && over) {
98
- const fieldType = active.data.current.fieldType
99
- const typeDef = FIELD_TYPES.find((ft) => ft.type === fieldType)
100
- if (!typeDef) return
101
-
102
- const newField: FormFieldDef = {
103
- id: crypto.randomUUID(),
104
- type: fieldType,
105
- label: t(`customForms.fieldType.${fieldType}`),
106
- col: 0,
107
- row: fields.length,
108
- colSpan: DEFAULT_COL_SPAN[fieldType] ?? 12,
109
- }
110
-
111
- if (['select', 'radio', 'tags'].includes(fieldType)) {
112
- newField.options = [
113
- { label: 'Option 1', value: 'option_1' },
114
- { label: 'Option 2', value: 'option_2' },
115
- ]
116
- }
117
-
118
- setFields((prev) => [...prev, newField])
119
- return
120
- }
121
-
122
- // Reorder within canvas
123
- if (active.id !== over?.id && over) {
124
- setFields((prev) => {
125
- const oldIndex = prev.findIndex((f) => f.id === active.id)
126
- const newIndex = prev.findIndex((f) => f.id === over.id)
127
- if (oldIndex === -1 || newIndex === -1) return prev
128
- const reordered = arrayMove(prev, oldIndex, newIndex)
129
- return reordered.map((f, i) => ({ ...f, row: i }))
130
- })
131
- }
132
- }, [fields, t])
133
-
134
- const handleSelectField = useCallback((field: FormFieldDef) => {
135
- setSelectedFieldId(field.id)
136
- }, [])
137
-
138
- const handleRemoveField = useCallback((fieldId: string) => {
139
- setFields((prev) => prev.filter((f) => f.id !== fieldId).map((f, i) => ({ ...f, row: i })))
140
- if (selectedFieldId === fieldId) setSelectedFieldId(null)
141
- }, [selectedFieldId])
142
-
143
- const handleFieldUpdate = useCallback((updated: FormFieldDef) => {
144
- setFields((prev) => prev.map((f) => (f.id === updated.id ? updated : f)))
145
- }, [])
146
-
147
- // --- Assistant surface: publish WHAT THE USER IS EDITING as context --------
148
- // The build action is the server-plane createOrUpdateForm tool (a pool RPC),
149
- // which works from any page. This surface only tells the model which template
150
- // is open and its current fields+ids, so "add an allergies field to THIS
151
- // form" resolves to the right templateId. No client-side mutation here.
152
- const surfaceStateRef = React.useRef({ templateId, name, description, category, fields })
153
- surfaceStateRef.current = { templateId, name, description, category, fields }
154
-
155
- const agentSurface = React.useMemo<AgentSurfaceContribution>(
156
- () => ({
157
- id: 'custom_forms.builder',
158
- pluginId: 'custom_forms',
159
- instructions: FORM_BUILDER_INSTRUCTIONS,
160
- describe: () => {
161
- const o = surfaceStateRef.current
162
- return {
163
- title: `Construtor de formulário — ${o.name?.trim() || 'novo modelo'}`,
164
- state: {
165
- // Pass this as `templateId` to createOrUpdateForm when editing THIS
166
- // form. Null = an unsaved new form (create, then it gets an id).
167
- templateId: o.templateId ?? null,
168
- unsaved: !o.templateId,
169
- name: o.name ?? '',
170
- category: o.category ?? 'general',
171
- description: o.description ?? '',
172
- fieldCount: o.fields.length,
173
- fields: o.fields.map((f) => ({
174
- id: f.id,
175
- type: f.type,
176
- label: f.label,
177
- required: !!f.required,
178
- ...(f.options?.length ? { options: f.options.map((x) => x.label) } : {}),
179
- })),
180
- },
181
- }
182
- },
183
- }),
184
- [],
185
- )
186
- useAgentSurface(agentSurface)
187
- // ---------------------------------------------------------------------------
188
-
189
- const handleFieldConfigSave = useCallback((updated: FormFieldDef) => {
190
- handleFieldUpdate(updated)
191
- setSelectedFieldId(null)
192
- }, [handleFieldUpdate])
193
-
194
- const handleSave = useCallback(async () => {
195
- if (!name.trim()) { toast.error(t('common.formIncomplete')); return }
196
- // Plan quantity guard (client-side, before the provider call) — only on
197
- // create. Opens the global UpgradeModal and aborts when the cap is reached.
198
- if (!templateId && (await guardTemplates()) === 'blocked') return
199
- setSaving(true)
200
- try {
201
- const schema: FormSchema = { fields, layout: { columns: 12 } }
202
-
203
- if (templateId) {
204
- await store.getState().updateTemplate(templateId, { name, description, category, schema })
205
- } else {
206
- await store.getState().createTemplate({ name, description, category, schema })
207
- invalidateLimit('form_templates')
208
- }
209
- onBack()
210
- } finally {
211
- setSaving(false)
212
- }
213
- }, [templateId, name, description, category, fields, store, onBack, guardTemplates])
214
-
215
- // Dirty detection — snapshot editable fields once async load settles
216
- const currentFields = { name, description, category, fields }
217
- const snapshot = React.useRef<string | null>(null)
218
- const loaded = !templateId || templateName !== null
219
- useEffect(() => {
220
- if (loaded && snapshot.current === null) snapshot.current = JSON.stringify(currentFields)
221
- }, [loaded])
222
- const dirty = snapshot.current !== null && JSON.stringify(currentFields) !== snapshot.current
223
-
224
- useSaveBar({
225
- dirty,
226
- saving,
227
- onSave: () => { void handleSave() },
228
- onDiscard: () => onBack(),
229
- saveLabel: t('customForms.builder.save'),
230
- })
231
-
232
- const selectedField = selectedFieldId
233
- ? fields.find((f) => f.id === selectedFieldId) ?? null
234
- : null
235
-
236
- const isNew = !templateId
237
- const breadcrumbLabel = isNew
238
- ? t('customForms.newTemplate')
239
- : templateName ?? t('customForms.editTemplate')
240
-
241
- return (
242
- <div className="space-y-4">
243
- {/* Breadcrumb */}
244
- <div className="flex items-center gap-1.5 text-sm">
245
- <button
246
- onClick={onBack}
247
- className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
248
- >
249
- <ArrowLeft className="h-3.5 w-3.5" />
250
- {t('customForms.templates')}
251
- </button>
252
- <span className="text-muted-foreground">/</span>
253
- <span className="font-medium">{breadcrumbLabel}</span>
254
- </div>
255
-
256
- {/* Form meta — card layout matching CRUD form */}
257
- <Card>
258
- <CardContent className="p-5 space-y-4">
259
- <div className="grid gap-4 sm:grid-cols-2">
260
- <div className="space-y-1.5">
261
- <label className="text-sm font-medium">
262
- {t('customForms.templateName')} <span className="text-destructive">*</span>
263
- </label>
264
- <Input
265
- value={name}
266
- onChange={(e) => setName(e.target.value)}
267
- placeholder={t('customForms.templateName')}
268
- className="h-9 text-sm"
269
- autoFocus={isNew}
270
- />
271
- </div>
272
- <div className="space-y-1.5">
273
- <label className="text-sm font-medium">{t('customForms.templateCategory')}</label>
274
- <Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
275
- <SelectTrigger className="h-9 text-sm">
276
- <SelectValue />
277
- </SelectTrigger>
278
- <SelectContent>
279
- {TEMPLATE_CATEGORIES.map((cat) => (
280
- <SelectItem key={cat} value={cat}>
281
- {t(`customForms.category.${cat}`)}
282
- </SelectItem>
283
- ))}
284
- </SelectContent>
285
- </Select>
286
- </div>
287
- </div>
288
- <div className="space-y-1.5">
289
- <label className="text-sm font-medium">{t('customForms.templateDescription')}</label>
290
- <Input
291
- value={description}
292
- onChange={(e) => setDescription(e.target.value)}
293
- placeholder={t('customForms.templateDescription')}
294
- className="h-9 text-sm"
295
- />
296
- </div>
297
- </CardContent>
298
- </Card>
299
-
300
- {/* Builder section header */}
301
- <div className="flex items-center justify-between">
302
- <div className="flex items-center gap-2">
303
- <h3 className="text-sm font-semibold">{t('customForms.builder.fields')}</h3>
304
- <Badge variant="secondary" className="text-[10px]">
305
- {t('customForms.builder.fieldCount', { count: String(fields.length) })}
306
- </Badge>
307
- </div>
308
- <div className="flex items-center gap-2">
309
- <Button
310
- variant="outline"
311
- size="sm"
312
- className="h-9"
313
- disabled={fields.length === 0}
314
- onClick={() => { setPreviewData({}); setPreviewMode('editor'); setShowPreview(true) }}
315
- >
316
- <Eye className="h-3.5 w-3.5 mr-1.5" />
317
- Preview
318
- </Button>
319
- </div>
320
- </div>
321
-
322
- {/* Builder body — drag palette + canvas */}
323
- <DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
324
- <div className="flex gap-4">
325
- <FieldPalette />
326
- <BuilderCanvas
327
- fields={fields}
328
- selectedFieldId={selectedFieldId}
329
- onSelectField={handleSelectField}
330
- onRemoveField={handleRemoveField}
331
- draggingFieldType={draggingFieldType}
332
- />
333
- </div>
334
-
335
- <DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}>
336
- {draggingFieldType ? (
337
- <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">
338
- {(() => {
339
- const def = FIELD_TYPES.find((ft) => ft.type === draggingFieldType)
340
- if (!def) return null
341
- const Icon = def.icon
342
- return (
343
- <>
344
- <Icon className="h-4 w-4 text-primary" />
345
- {t(`customForms.fieldType.${draggingFieldType}`)}
346
- </>
347
- )
348
- })()}
349
- </div>
350
- ) : null}
351
- </DragOverlay>
352
- </DndContext>
353
-
354
- {/* Field config drawer (right side) */}
355
- <FieldConfigDrawer
356
- field={selectedField}
357
- onSave={handleFieldConfigSave}
358
- onUpdate={handleFieldUpdate}
359
- onClose={() => setSelectedFieldId(null)}
360
- />
361
-
362
- {/* Preview — A4 paper overlay (portaled to body) */}
363
- {showPreview && createPortal(
364
- <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)}>
365
- <div
366
- className="relative w-full max-w-3xl bg-white rounded-modal shadow-lg"
367
- style={{ minHeight: '80vh' }}
368
- onClick={(e) => e.stopPropagation()}
369
- >
370
- {/* Toggle Editor ⟷ Impressão (centered) + close, on a sticky top bar. */}
371
- <div className="sticky top-0 z-10 flex items-center justify-center border-b bg-white/95 backdrop-blur px-4 py-2.5 rounded-t-modal">
372
- <div className="inline-flex rounded-lg border p-0.5 text-xs">
373
- <button
374
- onClick={() => setPreviewMode('editor')}
375
- className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-md font-medium transition-colors ${previewMode === 'editor' ? 'bg-neutral-900 text-white' : 'text-neutral-500 hover:text-neutral-800'}`}
376
- >
377
- <Pencil className="h-3.5 w-3.5" />
378
- {t('customForms.preview.editor')}
379
- </button>
380
- <button
381
- onClick={() => setPreviewMode('print')}
382
- className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-md font-medium transition-colors ${previewMode === 'print' ? 'bg-neutral-900 text-white' : 'text-neutral-500 hover:text-neutral-800'}`}
383
- >
384
- <Printer className="h-3.5 w-3.5" />
385
- {t('customForms.preview.print')}
386
- </button>
387
- </div>
388
- <button
389
- onClick={() => setShowPreview(false)}
390
- className="absolute right-3 top-1/2 -translate-y-1/2 flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors"
391
- >
392
- <span className="text-lg">✕</span>
393
- </button>
394
- </div>
395
-
396
- {previewMode === 'editor' ? (
397
- /* Editor — fillable form (same renderer used on the person's ficha) */
398
- <div className="px-10 py-8 sm:px-14 sm:py-10 text-neutral-900">
399
- <h1 className="text-2xl font-bold mb-1">{name || t('customForms.newTemplate')}</h1>
400
- {description && <p className="text-sm text-neutral-500 mb-8">{description}</p>}
401
- {!description && <div className="mb-8" />}
402
-
403
- <FormRenderer
404
- schema={{ fields, layout: { columns: 12 } }}
405
- data={previewData}
406
- onChange={setPreviewData}
407
- />
408
- </div>
409
- ) : (
410
- /* Impressão — exact print/PDF output rendered in an isolated iframe */
411
- <iframe
412
- title="print-preview"
413
- className="w-full rounded-b-modal"
414
- style={{ height: '75vh', border: 'none' }}
415
- srcDoc={buildPrintHtml(
416
- { id: 'preview', name: name || t('customForms.newTemplate'), schema: { fields, layout: { columns: 12 } } } as any,
417
- { title: name || t('customForms.newTemplate'), createdAt: new Date().toISOString(), status: 'draft', data: previewData } as any,
418
- )}
419
- />
420
- )}
421
- </div>
422
- </div>,
423
- document.body,
424
- )}
425
- </div>
426
- )
427
- }
@@ -1,256 +0,0 @@
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
- }