@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,250 @@
1
+ import React, { useEffect, useState, useCallback, useRef } from 'react'
2
+ import { FileText, Trash2, 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
+ setDocuments(result.data)
73
+ } finally {
74
+ setLoading(false)
75
+ }
76
+ }, [personId, provider])
77
+
78
+ useEffect(() => {
79
+ const timer = setTimeout(() => fetchDocs(), 50)
80
+ return () => clearTimeout(timer)
81
+ }, [fetchDocs])
82
+
83
+ useEffect(() => {
84
+ const unsub = store.subscribe(() => fetchDocs())
85
+ return unsub
86
+ }, [store, fetchDocs])
87
+
88
+ const handleDelete = useCallback(async (doc: FormDocument) => {
89
+ if (!window.confirm(t('customForms.deleteDocumentConfirm'))) return
90
+ await provider.deleteDocument(doc.id)
91
+ setDocuments((prev) => prev.filter((d) => d.id !== doc.id))
92
+ }, [provider, t])
93
+
94
+ // Drag and drop handlers
95
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
96
+ e.preventDefault()
97
+ dragCounter.current++
98
+ setIsDragOver(true)
99
+ }, [])
100
+
101
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
102
+ e.preventDefault()
103
+ dragCounter.current--
104
+ if (dragCounter.current === 0) setIsDragOver(false)
105
+ }, [])
106
+
107
+ const handleDragOver = useCallback((e: React.DragEvent) => {
108
+ e.preventDefault()
109
+ }, [])
110
+
111
+ const handleDrop = useCallback((e: React.DragEvent) => {
112
+ e.preventDefault()
113
+ dragCounter.current = 0
114
+ setIsDragOver(false)
115
+ const files = Array.from(e.dataTransfer.files)
116
+ if (files.length > 0 && onFileDrop) {
117
+ onFileDrop(files)
118
+ }
119
+ }, [onFileDrop])
120
+
121
+ const groups = groupByDate(documents)
122
+
123
+ if (loading) {
124
+ return (
125
+ <div className="space-y-4 py-4">
126
+ {Array.from({ length: 3 }).map((_, i) => (
127
+ <div key={i} className="flex items-center gap-3">
128
+ <div className="h-9 w-9 rounded-lg bg-muted animate-pulse shrink-0" />
129
+ <div className="flex-1 space-y-1.5">
130
+ <div className="h-3.5 w-2/3 bg-muted animate-pulse rounded" />
131
+ <div className="h-2.5 w-1/3 bg-muted animate-pulse rounded" />
132
+ </div>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ return (
140
+ <div
141
+ className={`relative min-h-[200px] rounded-xl border-2 border-dashed transition-colors ${
142
+ isDragOver
143
+ ? 'border-primary bg-primary/5'
144
+ : documents.length === 0
145
+ ? 'border-border'
146
+ : 'border-transparent'
147
+ }`}
148
+ onDragEnter={handleDragEnter}
149
+ onDragLeave={handleDragLeave}
150
+ onDragOver={handleDragOver}
151
+ onDrop={handleDrop}
152
+ >
153
+ {/* Drag overlay */}
154
+ {isDragOver && (
155
+ <div className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-xl bg-primary/5">
156
+ <Upload className="h-8 w-8 text-primary/50 mb-2" />
157
+ <p className="text-sm font-medium text-primary/70">Solte os arquivos aqui</p>
158
+ </div>
159
+ )}
160
+
161
+ {/* Empty state */}
162
+ {documents.length === 0 && !isDragOver && (
163
+ <div className="flex flex-col items-center justify-center py-12 text-center">
164
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-3">
165
+ <FileText className="h-5 w-5 text-muted-foreground" />
166
+ </div>
167
+ <p className="text-sm font-medium">{t('customForms.noDocuments')}</p>
168
+ <p className="text-xs text-muted-foreground mt-1 max-w-xs">
169
+ {t('customForms.noDocumentsDescription')}
170
+ </p>
171
+ <p className="text-[10px] text-muted-foreground mt-3 flex items-center gap-1">
172
+ <Upload className="h-3 w-3" />
173
+ Arraste arquivos para cá
174
+ </p>
175
+ </div>
176
+ )}
177
+
178
+ {/* Timeline */}
179
+ {groups.length > 0 && (
180
+ <div className="space-y-5 py-2">
181
+ {groups.map((group) => (
182
+ <div key={group.date}>
183
+ {/* Date header */}
184
+ <div className="flex items-center gap-2 mb-2">
185
+ <div className="h-px flex-1 bg-border" />
186
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1">
187
+ {group.label}
188
+ </span>
189
+ <div className="h-px flex-1 bg-border" />
190
+ </div>
191
+
192
+ {/* Documents for this date */}
193
+ <div className="space-y-1.5">
194
+ {group.items.map((doc) => {
195
+ const Icon = KIND_ICONS[doc.kind ?? 'form'] ?? FileText
196
+ const time = new Date(doc.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
197
+
198
+ return (
199
+ <div
200
+ key={doc.id}
201
+ className="group flex items-center gap-3 rounded-lg p-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
202
+ onClick={() => onView(doc)}
203
+ >
204
+ {/* Icon */}
205
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 shrink-0">
206
+ <Icon className="h-4 w-4 text-primary" />
207
+ </div>
208
+
209
+ {/* Content */}
210
+ <div className="flex-1 min-w-0">
211
+ <p className="text-sm font-medium truncate">
212
+ {doc.title ?? doc.templateName ?? 'Documento'}
213
+ </p>
214
+ <div className="flex items-center gap-1.5 mt-0.5">
215
+ {doc.templateName && (
216
+ <span className="text-[10px] text-muted-foreground">{doc.templateName}</span>
217
+ )}
218
+ <span className="text-[10px] text-muted-foreground">{time}</span>
219
+ </div>
220
+ </div>
221
+
222
+ {/* Status + actions */}
223
+ <Badge variant="secondary" className={`text-[10px] shrink-0 ${STATUS_COLORS[doc.status] ?? ''}`}>
224
+ {t(`customForms.status.${doc.status}`)}
225
+ </Badge>
226
+ <div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
227
+ <button
228
+ onClick={(e) => { e.stopPropagation(); onView(doc) }}
229
+ className="p-1.5 rounded-md hover:bg-muted"
230
+ >
231
+ <Eye className="h-3.5 w-3.5 text-muted-foreground" />
232
+ </button>
233
+ <button
234
+ onClick={(e) => { e.stopPropagation(); handleDelete(doc) }}
235
+ className="p-1.5 rounded-md hover:bg-destructive/10"
236
+ >
237
+ <Trash2 className="h-3.5 w-3.5 text-destructive" />
238
+ </button>
239
+ </div>
240
+ </div>
241
+ )
242
+ })}
243
+ </div>
244
+ </div>
245
+ ))}
246
+ </div>
247
+ )}
248
+ </div>
249
+ )
250
+ }
@@ -0,0 +1,203 @@
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 { useTranslation } from '@fayz-ai/core'
14
+ import type { FormFieldDef } from '../types'
15
+ import { FIELD_TYPES } from './FieldPalette'
16
+
17
+ const SIZE_OPTIONS = [
18
+ { span: 3, label: '1/4' },
19
+ { span: 4, label: '1/3' },
20
+ { span: 6, label: '1/2' },
21
+ { span: 8, label: '2/3' },
22
+ { span: 9, label: '3/4' },
23
+ { span: 12, label: 'Full' },
24
+ ]
25
+
26
+ interface FieldConfigDrawerProps {
27
+ field: FormFieldDef | null
28
+ onSave: (updated: FormFieldDef) => void
29
+ /** Live update without closing — used for visual changes like colSpan */
30
+ onUpdate?: (updated: FormFieldDef) => void
31
+ onClose: () => void
32
+ }
33
+
34
+ export function FieldConfigDrawer({ field, onSave, onUpdate, onClose }: FieldConfigDrawerProps) {
35
+ const t = useTranslation()
36
+ const [label, setLabel] = useState('')
37
+ const [placeholder, setPlaceholder] = useState('')
38
+ const [required, setRequired] = useState(false)
39
+ const [colSpan, setColSpan] = useState(12)
40
+ const [options, setOptions] = useState<Array<{ label: string; value: string }>>([])
41
+
42
+ // Sync state when field changes
43
+ useEffect(() => {
44
+ if (!field) return
45
+ setLabel(field.label)
46
+ setPlaceholder(field.placeholder ?? '')
47
+ setRequired(field.required ?? false)
48
+ setColSpan(field.colSpan)
49
+ setOptions(field.options ?? [])
50
+ }, [field?.id])
51
+
52
+ if (!field) return null
53
+
54
+ const hasOptions = ['select', 'radio', 'tags'].includes(field.type)
55
+ const fieldTypeDef = FIELD_TYPES.find((ft) => ft.type === field.type)
56
+ const Icon = fieldTypeDef?.icon
57
+
58
+ const handleSave = () => {
59
+ onSave({
60
+ ...field,
61
+ label,
62
+ placeholder: placeholder || undefined,
63
+ required,
64
+ colSpan: Math.max(1, Math.min(12, colSpan)),
65
+ options: hasOptions ? options.filter((o) => o.label.trim()) : field.options,
66
+ })
67
+ }
68
+
69
+ const addOption = () => {
70
+ setOptions([...options, { label: '', value: '' }])
71
+ }
72
+
73
+ const updateOption = (index: number, newLabel: string) => {
74
+ const updated = [...options]
75
+ updated[index] = { label: newLabel, value: newLabel.toLowerCase().replace(/\s+/g, '_') }
76
+ setOptions(updated)
77
+ }
78
+
79
+ const removeOption = (index: number) => {
80
+ setOptions(options.filter((_, i) => i !== index))
81
+ }
82
+
83
+ return (
84
+ <Sheet open={!!field} onOpenChange={(open) => { if (!open) onClose() }}>
85
+ <SheetContent className="w-80 sm:w-96" overlay="none">
86
+ <SheetHeader>
87
+ <SheetTitle className="flex items-center gap-2 text-sm">
88
+ {Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
89
+ {t(`customForms.fieldType.${field.type}`)}
90
+ </SheetTitle>
91
+ </SheetHeader>
92
+
93
+ <SheetBody className="space-y-4 py-4">
94
+ {/* Label */}
95
+ <div className="space-y-1.5">
96
+ <label className="text-xs font-medium">{t('customForms.builder.fieldLabel')}</label>
97
+ <Input
98
+ value={label}
99
+ onChange={(e) => setLabel(e.target.value)}
100
+ className="h-9 text-sm"
101
+ autoFocus
102
+ />
103
+ </div>
104
+
105
+ {/* Placeholder */}
106
+ {!['title', 'checkbox', 'image', 'gallery'].includes(field.type) && (
107
+ <div className="space-y-1.5">
108
+ <label className="text-xs font-medium">{t('customForms.builder.fieldPlaceholder')}</label>
109
+ <Input
110
+ value={placeholder}
111
+ onChange={(e) => setPlaceholder(e.target.value)}
112
+ className="h-9 text-sm"
113
+ />
114
+ </div>
115
+ )}
116
+
117
+ {/* Column span — visual skeleton selector */}
118
+ <div className="space-y-2">
119
+ <label className="text-xs font-medium">{t('customForms.builder.colSpan')}</label>
120
+ <div className="grid grid-cols-3 gap-2">
121
+ {SIZE_OPTIONS.map((opt) => (
122
+ <button
123
+ key={opt.span}
124
+ onClick={() => {
125
+ setColSpan(opt.span)
126
+ if (onUpdate && field) {
127
+ onUpdate({ ...field, colSpan: opt.span })
128
+ }
129
+ }}
130
+ className={`group relative rounded-lg border-2 p-2 transition-all ${
131
+ colSpan === opt.span
132
+ ? 'border-primary bg-primary/5'
133
+ : 'border-border hover:border-primary/30'
134
+ }`}
135
+ >
136
+ {/* Mini 12-col skeleton preview */}
137
+ <div className="flex gap-px h-3 mb-1.5">
138
+ <div
139
+ className={`rounded-sm ${colSpan === opt.span ? 'bg-primary/60' : 'bg-muted-foreground/30 group-hover:bg-primary/30'}`}
140
+ style={{ width: `${(opt.span / 12) * 100}%` }}
141
+ />
142
+ {opt.span < 12 && (
143
+ <div
144
+ className="rounded-sm bg-muted/60"
145
+ style={{ width: `${((12 - opt.span) / 12) * 100}%` }}
146
+ />
147
+ )}
148
+ </div>
149
+ <span className={`text-[10px] font-medium ${colSpan === opt.span ? 'text-primary' : 'text-muted-foreground'}`}>
150
+ {opt.label}
151
+ </span>
152
+ </button>
153
+ ))}
154
+ </div>
155
+ </div>
156
+
157
+ {/* Required */}
158
+ {field.type !== 'title' && (
159
+ <label className="flex items-center gap-2 cursor-pointer">
160
+ <input
161
+ type="checkbox"
162
+ checked={required}
163
+ onChange={(e) => setRequired(e.target.checked)}
164
+ className="rounded border-input accent-primary"
165
+ />
166
+ <span className="text-sm">{t('customForms.builder.fieldRequired')}</span>
167
+ </label>
168
+ )}
169
+
170
+ {/* Options */}
171
+ {hasOptions && (
172
+ <div className="space-y-2">
173
+ <label className="text-xs font-medium">{t('customForms.builder.fieldOptions')}</label>
174
+ {options.map((opt, i) => (
175
+ <div key={i} className="flex items-center gap-2">
176
+ <Input
177
+ value={opt.label}
178
+ onChange={(e) => updateOption(i, e.target.value)}
179
+ placeholder={`Option ${i + 1}`}
180
+ className="h-8 text-sm flex-1"
181
+ />
182
+ <button onClick={() => removeOption(i)} className="p-1 hover:bg-destructive/10 rounded">
183
+ <Trash2 className="h-3.5 w-3.5 text-destructive" />
184
+ </button>
185
+ </div>
186
+ ))}
187
+ <Button variant="outline" size="sm" onClick={addOption} className="h-8 text-xs w-full">
188
+ <Plus className="h-3 w-3 mr-1" />
189
+ {t('customForms.builder.addOption')}
190
+ </Button>
191
+ </div>
192
+ )}
193
+ </SheetBody>
194
+
195
+ <SheetFooter className="pt-4 border-t">
196
+ <Button size="sm" onClick={handleSave} className="w-full">
197
+ OK
198
+ </Button>
199
+ </SheetFooter>
200
+ </SheetContent>
201
+ </Sheet>
202
+ )
203
+ }
@@ -0,0 +1,81 @@
1
+ import React from 'react'
2
+ import { useDraggable } from '@dnd-kit/core'
3
+ import {
4
+ Heading, Type, AlignLeft, FileEdit, CalendarDays,
5
+ ChevronDown, CircleDot, Tags, CheckSquare,
6
+ Image, GalleryHorizontalEnd, Receipt,
7
+ } from 'lucide-react'
8
+ import { useTranslation } from '@fayz-ai/core'
9
+ import type { FormFieldType } from '../types'
10
+
11
+ export interface FieldTypeItem {
12
+ type: FormFieldType
13
+ icon: React.ElementType
14
+ }
15
+
16
+ export const DEFAULT_COL_SPAN: Record<string, number> = {
17
+ title: 12,
18
+ memo: 12,
19
+ richtext: 12,
20
+ gallery: 12,
21
+ budget: 12,
22
+ image: 6,
23
+ text: 6,
24
+ date: 4,
25
+ select: 6,
26
+ radio: 6,
27
+ tags: 8,
28
+ checkbox: 4,
29
+ }
30
+
31
+ export const FIELD_TYPES: FieldTypeItem[] = [
32
+ { type: 'title', icon: Heading },
33
+ { type: 'text', icon: Type },
34
+ { type: 'memo', icon: AlignLeft },
35
+ { type: 'richtext', icon: FileEdit },
36
+ { type: 'date', icon: CalendarDays },
37
+ { type: 'select', icon: ChevronDown },
38
+ { type: 'radio', icon: CircleDot },
39
+ { type: 'tags', icon: Tags },
40
+ { type: 'checkbox', icon: CheckSquare },
41
+ { type: 'image', icon: Image },
42
+ { type: 'gallery', icon: GalleryHorizontalEnd },
43
+ { type: 'budget', icon: Receipt },
44
+ ]
45
+
46
+ function DraggableFieldType({ item }: { item: FieldTypeItem }) {
47
+ const t = useTranslation()
48
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
49
+ id: `palette-${item.type}`,
50
+ data: { source: 'palette', fieldType: item.type },
51
+ })
52
+
53
+ const Icon = item.icon
54
+
55
+ return (
56
+ <button
57
+ ref={setNodeRef}
58
+ {...listeners}
59
+ {...attributes}
60
+ className={`flex items-center gap-2 w-full px-3 py-2 rounded-lg border bg-card text-sm font-medium transition-all hover:shadow-sm hover:border-primary/30 cursor-grab active:cursor-grabbing ${isDragging ? 'opacity-30' : ''}`}
61
+ >
62
+ <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
63
+ <span className="truncate">{t(`customForms.fieldType.${item.type}`)}</span>
64
+ </button>
65
+ )
66
+ }
67
+
68
+ export function FieldPalette() {
69
+ const t = useTranslation()
70
+
71
+ return (
72
+ <div className="w-44 shrink-0 space-y-1.5">
73
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1 mb-2">
74
+ {t('customForms.builder.fields')}
75
+ </p>
76
+ {FIELD_TYPES.map((item) => (
77
+ <DraggableFieldType key={item.type} item={item} />
78
+ ))}
79
+ </div>
80
+ )
81
+ }