@fayz-ai/plugin-forms 0.9.0 → 0.9.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 (59) hide show
  1. package/README.md +3 -3
  2. package/dist/{FormBuilder-7SWZQKFN.js → FormBuilder-JCNHXQXT.js} +4 -4
  3. package/dist/{FormBuilder-7SWZQKFN.js.map → FormBuilder-JCNHXQXT.js.map} +1 -1
  4. package/dist/{chunk-W2ZNJGQC.js → chunk-JCAWSDX6.js} +3 -3
  5. package/dist/chunk-JCAWSDX6.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/{CustomFormsContext.d.ts → context.d.ts} +1 -1
  9. package/dist/context.d.ts.map +1 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/agent-tools.d.ts.map +1 -0
  15. package/dist/lib/document-types.d.ts.map +1 -0
  16. package/package.json +5 -7
  17. package/dist/CustomFormsContext.d.ts.map +0 -1
  18. package/dist/agent-tools.d.ts.map +0 -1
  19. package/dist/chunk-W2ZNJGQC.js.map +0 -1
  20. package/dist/document-types.d.ts.map +0 -1
  21. package/src/CustomFormsContext.tsx +0 -28
  22. package/src/agent-tools.ts +0 -73
  23. package/src/components/AddDocumentDropdown.tsx +0 -92
  24. package/src/components/BuilderCanvas.tsx +0 -87
  25. package/src/components/BuilderField.tsx +0 -81
  26. package/src/components/DocumentFormDialog.tsx +0 -155
  27. package/src/components/DocumentList.tsx +0 -254
  28. package/src/components/FieldConfigDialog.tsx +0 -243
  29. package/src/components/FieldPalette.tsx +0 -81
  30. package/src/components/FormBuilder.tsx +0 -427
  31. package/src/components/FormRenderer.tsx +0 -256
  32. package/src/components/FormViewer.tsx +0 -94
  33. package/src/components/PersonDocumentsWidget.tsx +0 -229
  34. package/src/components/TemplateSelector.tsx +0 -91
  35. package/src/config.ts +0 -43
  36. package/src/data/index.ts +0 -3
  37. package/src/data/mock.ts +0 -193
  38. package/src/data/supabase.ts +0 -408
  39. package/src/data/tables.ts +0 -7
  40. package/src/data/types.ts +0 -33
  41. package/src/document-types.ts +0 -51
  42. package/src/index.ts +0 -234
  43. package/src/lib/person-fields.ts +0 -67
  44. package/src/lib/print.ts +0 -207
  45. package/src/lib/tenant.ts +0 -9
  46. package/src/locales/en.ts +0 -95
  47. package/src/locales/index.ts +0 -7
  48. package/src/locales/pt-BR.ts +0 -95
  49. package/src/migrations/000_plg_rename.sql +0 -21
  50. package/src/migrations/001_frm_base.sql +0 -174
  51. package/src/migrations/002_document_archetype.sql +0 -223
  52. package/src/migrations/003_agent_rpcs.sql +0 -174
  53. package/src/migrations/index.ts +0 -610
  54. package/src/store.ts +0 -167
  55. package/src/types.ts +0 -217
  56. package/src/views/CustomFormsSettingsTab.tsx +0 -105
  57. package/src/views/TemplateListView.tsx +0 -174
  58. /package/dist/{agent-tools.d.ts → lib/agent-tools.d.ts} +0 -0
  59. /package/dist/{document-types.d.ts → lib/document-types.d.ts} +0 -0
@@ -1,155 +0,0 @@
1
- import React, { useState, useEffect, useCallback } from 'react'
2
- import { ArrowLeft } from 'lucide-react'
3
- import { useSaveBar } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import { useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
6
- import type { CustomFormsDataProvider } from '../data/types'
7
- import type { CustomFormsStore } from '../store'
8
- import type { FormTemplate, FormDocument } from '../types'
9
- import { FormRenderer } from './FormRenderer'
10
- import { prefillFromPerson } from '../lib/person-fields'
11
-
12
- function generateTitle(templateName: string, personName?: string): string {
13
- const date = new Date().toLocaleDateString()
14
- return personName
15
- ? `${templateName} — ${personName} — ${date}`
16
- : `${templateName} — ${date}`
17
- }
18
-
19
- interface DocumentFormDialogProps {
20
- template: FormTemplate | null
21
- existingDocument?: FormDocument
22
- personId: string
23
- personName?: string
24
- /** Full person record — mapped fields are pre-filled from it on new documents. */
25
- person?: Record<string, unknown>
26
- provider: CustomFormsDataProvider
27
- store: CustomFormsStore
28
- onSaved: () => void
29
- onBack: () => void
30
- }
31
-
32
- export function DocumentFormDialog({
33
- template: initialTemplate,
34
- existingDocument,
35
- personId,
36
- personName,
37
- person,
38
- provider,
39
- store,
40
- onSaved,
41
- onBack,
42
- }: DocumentFormDialogProps) {
43
- const t = useTranslation()
44
- const guardDocuments = useLimitGuard('documents_month')
45
- const [template, setTemplate] = useState<FormTemplate | null>(initialTemplate)
46
- const [data, setData] = useState<Record<string, unknown>>(() =>
47
- existingDocument
48
- ? existingDocument.data
49
- : prefillFromPerson(initialTemplate?.schema, person),
50
- )
51
- const [saving, setSaving] = useState(false)
52
-
53
- useEffect(() => {
54
- if (existingDocument && !template) {
55
- provider.getTemplateById(existingDocument.templateId).then(setTemplate)
56
- }
57
- }, [existingDocument, template, provider])
58
-
59
- // When creating from a template that arrives asynchronously, apply prefill
60
- // once the schema is known (guarded so it never clobbers user edits).
61
- const prefilled = React.useRef(!!initialTemplate || !!existingDocument)
62
- useEffect(() => {
63
- if (prefilled.current || !template || existingDocument) return
64
- prefilled.current = true
65
- setData((prev) => prefillFromPerson(template.schema, person, prev))
66
- }, [template, existingDocument, person])
67
-
68
- const handleSave = useCallback(
69
- async (status: 'draft' | 'completed') => {
70
- if (!template) return
71
- // Plan monthly-quota guard (client-side, before the provider call) — only
72
- // on create. Opens the global UpgradeModal and aborts when the cap is hit.
73
- if (!existingDocument && (await guardDocuments()) === 'blocked') return
74
- setSaving(true)
75
- try {
76
- const autoTitle = generateTitle(template.name, personName)
77
- if (existingDocument) {
78
- await store.getState().updateDocument(existingDocument.id, { data, status })
79
- } else {
80
- await store.getState().createDocument({
81
- templateId: template.id,
82
- personId,
83
- title: autoTitle,
84
- data,
85
- status,
86
- })
87
- invalidateLimit('documents_month')
88
- }
89
- onSaved()
90
- } finally {
91
- setSaving(false)
92
- }
93
- },
94
- [template, existingDocument, data, personId, personName, store, onSaved, guardDocuments],
95
- )
96
-
97
- // Dirty detection drives the floating SaveBar. The baseline is the document's
98
- // ORIGINAL saved data (empty for a brand-new document), so a new document that
99
- // arrives pre-filled from the person's record counts as dirty immediately and
100
- // the floating "unsaved changes" bar shows right away.
101
- const snapshot = React.useRef<string | null>(null)
102
- useEffect(() => {
103
- if (template && snapshot.current === null) {
104
- snapshot.current = JSON.stringify(existingDocument?.data ?? {})
105
- }
106
- }, [template, existingDocument])
107
- const dirty = snapshot.current !== null && JSON.stringify(data) !== snapshot.current
108
-
109
- useSaveBar({
110
- dirty,
111
- saving,
112
- onSave: () => { void handleSave('completed') },
113
- onDiscard: () => onBack(),
114
- saveLabel: t('customForms.saveAndComplete'),
115
- })
116
-
117
- if (!template) {
118
- return (
119
- <div className="flex items-center justify-center py-12">
120
- <div className="h-5 w-5 border-2 border-primary border-t-transparent animate-spin rounded-full" />
121
- </div>
122
- )
123
- }
124
-
125
- return (
126
- <div className="space-y-4">
127
- {/* Header */}
128
- <div className="flex items-center gap-3">
129
- <button
130
- onClick={onBack}
131
- className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
132
- >
133
- <ArrowLeft className="h-3.5 w-3.5" />
134
- </button>
135
- <div className="flex-1 min-w-0">
136
- <h3 className="font-semibold text-sm">{template.name}</h3>
137
- <p className="text-xs text-muted-foreground">
138
- {personName && <span>{personName} &middot; </span>}
139
- {new Date().toLocaleDateString()}
140
- </p>
141
- </div>
142
- </div>
143
-
144
- {/* Form fields — saving is driven entirely by the floating SaveBar
145
- (unsaved-changes bar), matching the rest of the CRUD detail UX. */}
146
- <div className="rounded-xl border p-4">
147
- <FormRenderer
148
- schema={template.schema}
149
- data={data}
150
- onChange={setData}
151
- />
152
- </div>
153
- </div>
154
- )
155
- }
@@ -1,254 +0,0 @@
1
- import React, { useEffect, useState, useCallback, useRef } from 'react'
2
- import { FileText, Archive, Eye, Upload, Image as ImageIcon, Paperclip } from 'lucide-react'
3
- import { Badge } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import type { CustomFormsDataProvider } from '../data/types'
6
- import type { CustomFormsStore } from '../store'
7
- import type { FormDocument, DocumentStatus } from '../types'
8
-
9
- interface DocumentListProps {
10
- personId: string
11
- provider: CustomFormsDataProvider
12
- store: CustomFormsStore
13
- onView: (doc: FormDocument) => void
14
- onFileDrop?: (files: File[]) => void
15
- }
16
-
17
- const STATUS_COLORS: Record<DocumentStatus, 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
- const KIND_ICONS: Record<string, React.ElementType> = {
25
- form: FileText,
26
- image: ImageIcon,
27
- attachment: Paperclip,
28
- prescription: FileText,
29
- contract: FileText,
30
- }
31
-
32
- function formatDateLabel(dateStr: string): string {
33
- const date = new Date(dateStr)
34
- const now = new Date()
35
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
36
- const docDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
37
- const diffDays = Math.round((today.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
38
-
39
- if (diffDays === 0) return 'Hoje'
40
- if (diffDays === 1) return 'Ontem'
41
- if (diffDays < 7) return `${diffDays} dias atrás`
42
- return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })
43
- }
44
-
45
- function groupByDate(docs: FormDocument[]): Array<{ date: string; label: string; items: FormDocument[] }> {
46
- const groups: Map<string, FormDocument[]> = new Map()
47
- for (const doc of docs) {
48
- const dateKey = doc.createdAt.slice(0, 10)
49
- if (!groups.has(dateKey)) groups.set(dateKey, [])
50
- groups.get(dateKey)!.push(doc)
51
- }
52
- return Array.from(groups.entries())
53
- .sort(([a], [b]) => b.localeCompare(a))
54
- .map(([date, items]) => ({
55
- date,
56
- label: formatDateLabel(date),
57
- items,
58
- }))
59
- }
60
-
61
- export function DocumentList({ personId, provider, store, onView, onFileDrop }: DocumentListProps) {
62
- const t = useTranslation()
63
- const [documents, setDocuments] = useState<FormDocument[]>([])
64
- const [loading, setLoading] = useState(true)
65
- const [isDragOver, setIsDragOver] = useState(false)
66
- const dragCounter = useRef(0)
67
-
68
- const fetchDocs = useCallback(async () => {
69
- setLoading(true)
70
- try {
71
- const result = await provider.getDocuments({ personId })
72
- // Archived docs are kept (not deleted) but drop out of the active list.
73
- setDocuments(result.data.filter((d) => d.status !== 'archived'))
74
- } finally {
75
- setLoading(false)
76
- }
77
- }, [personId, provider])
78
-
79
- useEffect(() => {
80
- const timer = setTimeout(() => fetchDocs(), 50)
81
- return () => clearTimeout(timer)
82
- }, [fetchDocs])
83
-
84
- useEffect(() => {
85
- const unsub = store.subscribe(() => fetchDocs())
86
- return unsub
87
- }, [store, fetchDocs])
88
-
89
- // Archive, not delete: a filled document is a record of what was signed —
90
- // it is moved out of the active list, never destroyed.
91
- const handleArchive = useCallback(async (doc: FormDocument) => {
92
- if (!window.confirm(t('customForms.archiveDocumentConfirm'))) return
93
- await provider.updateDocument(doc.id, { status: 'archived' })
94
- setDocuments((prev) => prev.filter((d) => d.id !== doc.id))
95
- }, [provider, t])
96
-
97
- // Drag and drop handlers
98
- const handleDragEnter = useCallback((e: React.DragEvent) => {
99
- e.preventDefault()
100
- dragCounter.current++
101
- setIsDragOver(true)
102
- }, [])
103
-
104
- const handleDragLeave = useCallback((e: React.DragEvent) => {
105
- e.preventDefault()
106
- dragCounter.current--
107
- if (dragCounter.current === 0) setIsDragOver(false)
108
- }, [])
109
-
110
- const handleDragOver = useCallback((e: React.DragEvent) => {
111
- e.preventDefault()
112
- }, [])
113
-
114
- const handleDrop = useCallback((e: React.DragEvent) => {
115
- e.preventDefault()
116
- dragCounter.current = 0
117
- setIsDragOver(false)
118
- const files = Array.from(e.dataTransfer.files)
119
- if (files.length > 0 && onFileDrop) {
120
- onFileDrop(files)
121
- }
122
- }, [onFileDrop])
123
-
124
- const groups = groupByDate(documents)
125
-
126
- if (loading) {
127
- return (
128
- <div className="space-y-4 py-4">
129
- {Array.from({ length: 3 }).map((_, i) => (
130
- <div key={i} className="flex items-center gap-3">
131
- <div className="h-9 w-9 rounded-lg bg-muted animate-pulse shrink-0" />
132
- <div className="flex-1 space-y-1.5">
133
- <div className="h-3.5 w-2/3 bg-muted animate-pulse rounded" />
134
- <div className="h-2.5 w-1/3 bg-muted animate-pulse rounded" />
135
- </div>
136
- </div>
137
- ))}
138
- </div>
139
- )
140
- }
141
-
142
- return (
143
- <div
144
- className={`relative min-h-[200px] rounded-xl border-2 border-dashed transition-colors ${
145
- isDragOver
146
- ? 'border-primary bg-primary/5'
147
- : documents.length === 0
148
- ? 'border-border'
149
- : 'border-transparent'
150
- }`}
151
- onDragEnter={handleDragEnter}
152
- onDragLeave={handleDragLeave}
153
- onDragOver={handleDragOver}
154
- onDrop={handleDrop}
155
- >
156
- {/* Drag overlay */}
157
- {isDragOver && (
158
- <div className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-xl bg-primary/5">
159
- <Upload className="h-8 w-8 text-primary/50 mb-2" />
160
- <p className="text-sm font-medium text-primary/70">Solte os arquivos aqui</p>
161
- </div>
162
- )}
163
-
164
- {/* Empty state */}
165
- {documents.length === 0 && !isDragOver && (
166
- <div className="flex flex-col items-center justify-center py-12 text-center">
167
- <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-3">
168
- <FileText className="h-5 w-5 text-muted-foreground" />
169
- </div>
170
- <p className="text-sm font-medium">{t('customForms.noDocuments')}</p>
171
- <p className="text-xs text-muted-foreground mt-1 max-w-xs">
172
- {t('customForms.noDocumentsDescription')}
173
- </p>
174
- <p className="text-[10px] text-muted-foreground mt-3 flex items-center gap-1">
175
- <Upload className="h-3 w-3" />
176
- Arraste arquivos para cá
177
- </p>
178
- </div>
179
- )}
180
-
181
- {/* Timeline */}
182
- {groups.length > 0 && (
183
- <div className="space-y-5 py-2">
184
- {groups.map((group) => (
185
- <div key={group.date}>
186
- {/* Date header */}
187
- <div className="flex items-center gap-2 mb-2">
188
- <div className="h-px flex-1 bg-border" />
189
- <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1">
190
- {group.label}
191
- </span>
192
- <div className="h-px flex-1 bg-border" />
193
- </div>
194
-
195
- {/* Documents for this date */}
196
- <div className="space-y-1.5">
197
- {group.items.map((doc) => {
198
- const Icon = KIND_ICONS[doc.kind ?? 'form'] ?? FileText
199
- const time = new Date(doc.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
200
-
201
- return (
202
- <div
203
- key={doc.id}
204
- className="group flex items-center gap-3 rounded-lg p-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
205
- onClick={() => onView(doc)}
206
- >
207
- {/* Icon */}
208
- <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 shrink-0">
209
- <Icon className="h-4 w-4 text-primary" />
210
- </div>
211
-
212
- {/* Content */}
213
- <div className="flex-1 min-w-0">
214
- <p className="text-sm font-medium truncate">
215
- {doc.title ?? doc.templateName ?? 'Documento'}
216
- </p>
217
- <div className="flex items-center gap-1.5 mt-0.5">
218
- {doc.templateName && (
219
- <span className="text-[10px] text-muted-foreground">{doc.templateName}</span>
220
- )}
221
- <span className="text-[10px] text-muted-foreground">{time}</span>
222
- </div>
223
- </div>
224
-
225
- {/* Status + actions */}
226
- <Badge variant="secondary" className={`text-[10px] shrink-0 ${STATUS_COLORS[doc.status] ?? ''}`}>
227
- {t(`customForms.status.${doc.status}`)}
228
- </Badge>
229
- <div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
230
- <button
231
- onClick={(e) => { e.stopPropagation(); onView(doc) }}
232
- className="p-1.5 rounded-md hover:bg-muted"
233
- >
234
- <Eye className="h-3.5 w-3.5 text-muted-foreground" />
235
- </button>
236
- <button
237
- onClick={(e) => { e.stopPropagation(); handleArchive(doc) }}
238
- className="p-1.5 rounded-md hover:bg-muted"
239
- title={t('customForms.archive')}
240
- >
241
- <Archive className="h-3.5 w-3.5 text-muted-foreground" />
242
- </button>
243
- </div>
244
- </div>
245
- )
246
- })}
247
- </div>
248
- </div>
249
- ))}
250
- </div>
251
- )}
252
- </div>
253
- )
254
- }
@@ -1,243 +0,0 @@
1
- import React, { useState, useEffect } from 'react'
2
- import { Plus, Trash2 } from 'lucide-react'
3
- import {
4
- Sheet,
5
- SheetContent,
6
- SheetHeader,
7
- SheetTitle,
8
- SheetBody,
9
- SheetFooter,
10
- } from '@fayz-ai/ui'
11
- import { Button } from '@fayz-ai/ui'
12
- import { Input } from '@fayz-ai/ui'
13
- import {
14
- Select,
15
- SelectContent,
16
- SelectItem,
17
- SelectTrigger,
18
- SelectValue,
19
- } from '@fayz-ai/ui'
20
- import { useTranslation } from '@fayz-ai/core'
21
- import type { FormFieldDef } from '../types'
22
- import { FIELD_TYPES } from './FieldPalette'
23
- import { PERSON_FIELD_OPTIONS } from '../lib/person-fields'
24
-
25
- /** Field types that can be auto-filled from a person record. */
26
- const MAPPABLE_TYPES = ['text', 'memo', 'richtext', 'date']
27
- const MAP_NONE = '__none__'
28
-
29
- const SIZE_OPTIONS = [
30
- { span: 3, label: '1/4' },
31
- { span: 4, label: '1/3' },
32
- { span: 6, label: '1/2' },
33
- { span: 8, label: '2/3' },
34
- { span: 9, label: '3/4' },
35
- { span: 12, label: 'Full' },
36
- ]
37
-
38
- interface FieldConfigDrawerProps {
39
- field: FormFieldDef | null
40
- onSave: (updated: FormFieldDef) => void
41
- /** Live update without closing — used for visual changes like colSpan */
42
- onUpdate?: (updated: FormFieldDef) => void
43
- onClose: () => void
44
- }
45
-
46
- export function FieldConfigDrawer({ field, onSave, onUpdate, onClose }: FieldConfigDrawerProps) {
47
- const t = useTranslation()
48
- const [label, setLabel] = useState('')
49
- const [placeholder, setPlaceholder] = useState('')
50
- const [required, setRequired] = useState(false)
51
- const [colSpan, setColSpan] = useState(12)
52
- const [options, setOptions] = useState<Array<{ label: string; value: string }>>([])
53
- const [map, setMap] = useState<string>('')
54
-
55
- // Sync state when field changes
56
- useEffect(() => {
57
- if (!field) return
58
- setLabel(field.label)
59
- setPlaceholder(field.placeholder ?? '')
60
- setRequired(field.required ?? false)
61
- setColSpan(field.colSpan)
62
- setOptions(field.options ?? [])
63
- setMap(field.map ?? '')
64
- }, [field?.id])
65
-
66
- if (!field) return null
67
-
68
- const hasOptions = ['select', 'radio', 'tags'].includes(field.type)
69
- const canMap = MAPPABLE_TYPES.includes(field.type)
70
- const fieldTypeDef = FIELD_TYPES.find((ft) => ft.type === field.type)
71
- const Icon = fieldTypeDef?.icon
72
-
73
- const handleSave = () => {
74
- onSave({
75
- ...field,
76
- label,
77
- placeholder: placeholder || undefined,
78
- required,
79
- colSpan: Math.max(1, Math.min(12, colSpan)),
80
- options: hasOptions ? options.filter((o) => o.label.trim()) : field.options,
81
- map: canMap && map ? map : undefined,
82
- })
83
- }
84
-
85
- const addOption = () => {
86
- setOptions([...options, { label: '', value: '' }])
87
- }
88
-
89
- const updateOption = (index: number, newLabel: string) => {
90
- const updated = [...options]
91
- updated[index] = { label: newLabel, value: newLabel.toLowerCase().replace(/\s+/g, '_') }
92
- setOptions(updated)
93
- }
94
-
95
- const removeOption = (index: number) => {
96
- setOptions(options.filter((_, i) => i !== index))
97
- }
98
-
99
- return (
100
- <Sheet open={!!field} onOpenChange={(open) => { if (!open) onClose() }}>
101
- <SheetContent className="w-80 sm:w-96" overlay="none">
102
- <SheetHeader>
103
- <SheetTitle className="flex items-center gap-2 text-sm">
104
- {Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
105
- {t(`customForms.fieldType.${field.type}`)}
106
- </SheetTitle>
107
- </SheetHeader>
108
-
109
- <SheetBody className="space-y-4 py-4">
110
- {/* Label */}
111
- <div className="space-y-1.5">
112
- <label className="text-xs font-medium">{t('customForms.builder.fieldLabel')}</label>
113
- <Input
114
- value={label}
115
- onChange={(e) => setLabel(e.target.value)}
116
- className="h-9 text-sm"
117
- autoFocus
118
- />
119
- </div>
120
-
121
- {/* Placeholder */}
122
- {!['title', 'checkbox', 'image', 'gallery'].includes(field.type) && (
123
- <div className="space-y-1.5">
124
- <label className="text-xs font-medium">{t('customForms.builder.fieldPlaceholder')}</label>
125
- <Input
126
- value={placeholder}
127
- onChange={(e) => setPlaceholder(e.target.value)}
128
- className="h-9 text-sm"
129
- />
130
- </div>
131
- )}
132
-
133
- {/* Column span — visual skeleton selector */}
134
- <div className="space-y-2">
135
- <label className="text-xs font-medium">{t('customForms.builder.colSpan')}</label>
136
- <div className="grid grid-cols-3 gap-2">
137
- {SIZE_OPTIONS.map((opt) => (
138
- <button
139
- key={opt.span}
140
- onClick={() => {
141
- setColSpan(opt.span)
142
- if (onUpdate && field) {
143
- onUpdate({ ...field, colSpan: opt.span })
144
- }
145
- }}
146
- className={`group relative rounded-lg border-2 p-2 transition-all ${
147
- colSpan === opt.span
148
- ? 'border-primary bg-primary/5'
149
- : 'border-border hover:border-primary/30'
150
- }`}
151
- >
152
- {/* Mini 12-col skeleton preview */}
153
- <div className="flex gap-px h-3 mb-1.5">
154
- <div
155
- className={`rounded-sm ${colSpan === opt.span ? 'bg-primary/60' : 'bg-muted-foreground/30 group-hover:bg-primary/30'}`}
156
- style={{ width: `${(opt.span / 12) * 100}%` }}
157
- />
158
- {opt.span < 12 && (
159
- <div
160
- className="rounded-sm bg-muted/60"
161
- style={{ width: `${((12 - opt.span) / 12) * 100}%` }}
162
- />
163
- )}
164
- </div>
165
- <span className={`text-[10px] font-medium ${colSpan === opt.span ? 'text-primary' : 'text-muted-foreground'}`}>
166
- {opt.label}
167
- </span>
168
- </button>
169
- ))}
170
- </div>
171
- </div>
172
-
173
- {/* Required */}
174
- {field.type !== 'title' && (
175
- <label className="flex items-center gap-2 cursor-pointer">
176
- <input
177
- type="checkbox"
178
- checked={required}
179
- onChange={(e) => setRequired(e.target.checked)}
180
- className="rounded border-input accent-primary"
181
- />
182
- <span className="text-sm">{t('customForms.builder.fieldRequired')}</span>
183
- </label>
184
- )}
185
-
186
- {/* Auto-fill from person record */}
187
- {canMap && (
188
- <div className="space-y-1.5">
189
- <label className="text-xs font-medium">{t('customForms.builder.mapToPerson')}</label>
190
- <Select
191
- value={map || MAP_NONE}
192
- onValueChange={(v) => setMap(v === MAP_NONE ? '' : v)}
193
- >
194
- <SelectTrigger className="h-9 text-sm">
195
- <SelectValue />
196
- </SelectTrigger>
197
- <SelectContent>
198
- <SelectItem value={MAP_NONE}>{t('customForms.builder.mapNone')}</SelectItem>
199
- {PERSON_FIELD_OPTIONS.map((opt) => (
200
- <SelectItem key={opt.value} value={opt.value}>
201
- {t(opt.labelKey)}
202
- </SelectItem>
203
- ))}
204
- </SelectContent>
205
- </Select>
206
- <p className="text-[10px] text-muted-foreground">{t('customForms.builder.mapHint')}</p>
207
- </div>
208
- )}
209
-
210
- {/* Options */}
211
- {hasOptions && (
212
- <div className="space-y-2">
213
- <label className="text-xs font-medium">{t('customForms.builder.fieldOptions')}</label>
214
- {options.map((opt, i) => (
215
- <div key={i} className="flex items-center gap-2">
216
- <Input
217
- value={opt.label}
218
- onChange={(e) => updateOption(i, e.target.value)}
219
- placeholder={`Option ${i + 1}`}
220
- className="h-8 text-sm flex-1"
221
- />
222
- <button onClick={() => removeOption(i)} className="p-1 hover:bg-destructive/10 rounded">
223
- <Trash2 className="h-3.5 w-3.5 text-destructive" />
224
- </button>
225
- </div>
226
- ))}
227
- <Button variant="outline" size="sm" onClick={addOption} className="h-8 text-xs w-full">
228
- <Plus className="h-3 w-3 mr-1" />
229
- {t('customForms.builder.addOption')}
230
- </Button>
231
- </div>
232
- )}
233
- </SheetBody>
234
-
235
- <SheetFooter className="pt-4 border-t">
236
- <Button size="sm" onClick={handleSave} className="w-full">
237
- OK
238
- </Button>
239
- </SheetFooter>
240
- </SheetContent>
241
- </Sheet>
242
- )
243
- }