@fayz-ai/plugin-forms 0.2.0 → 0.8.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 (69) hide show
  1. package/README.md +2 -0
  2. package/dist/{FormBuilder-KDANEL6Z.js → FormBuilder-7SWZQKFN.js} +130 -23
  3. package/dist/FormBuilder-7SWZQKFN.js.map +1 -0
  4. package/dist/{FormBuilder-4VKYVZAC.cjs → FormBuilder-YB75LEEH.cjs} +129 -22
  5. package/dist/FormBuilder-YB75LEEH.cjs.map +1 -0
  6. package/dist/agent-tools.d.ts +4 -0
  7. package/dist/agent-tools.d.ts.map +1 -0
  8. package/dist/chunk-QM2AF3DZ.cjs +446 -0
  9. package/dist/chunk-QM2AF3DZ.cjs.map +1 -0
  10. package/dist/chunk-W2ZNJGQC.js +438 -0
  11. package/dist/chunk-W2ZNJGQC.js.map +1 -0
  12. package/dist/components/DocumentFormDialog.d.ts +3 -1
  13. package/dist/components/DocumentFormDialog.d.ts.map +1 -1
  14. package/dist/components/DocumentList.d.ts.map +1 -1
  15. package/dist/components/FieldConfigDialog.d.ts.map +1 -1
  16. package/dist/components/FormBuilder.d.ts.map +1 -1
  17. package/dist/components/FormViewer.d.ts.map +1 -1
  18. package/dist/components/PersonDocumentsWidget.d.ts.map +1 -1
  19. package/dist/data/supabase.d.ts.map +1 -1
  20. package/dist/data/tables.d.ts +7 -0
  21. package/dist/data/tables.d.ts.map +1 -0
  22. package/dist/index.cjs +168 -72
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +168 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/person-fields.d.ts +16 -0
  28. package/dist/lib/person-fields.d.ts.map +1 -0
  29. package/dist/lib/print.d.ts +17 -0
  30. package/dist/lib/print.d.ts.map +1 -0
  31. package/dist/lib/tenant.d.ts.map +1 -1
  32. package/dist/locales/en.d.ts.map +1 -1
  33. package/dist/locales/pt-BR.d.ts.map +1 -1
  34. package/dist/migrations/index.d.ts +9 -0
  35. package/dist/migrations/index.d.ts.map +1 -0
  36. package/dist/types.d.ts +15 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -1
  39. package/dist/views/TemplateListView.d.ts.map +1 -1
  40. package/package.json +10 -4
  41. package/src/agent-tools.ts +73 -0
  42. package/src/components/DocumentFormDialog.tsx +37 -16
  43. package/src/components/DocumentList.tsx +12 -8
  44. package/src/components/FieldConfigDialog.tsx +40 -0
  45. package/src/components/FormBuilder.tsx +104 -22
  46. package/src/components/FormViewer.tsx +11 -1
  47. package/src/components/PersonDocumentsWidget.tsx +10 -1
  48. package/src/data/supabase.ts +22 -19
  49. package/src/data/tables.ts +7 -0
  50. package/src/index.ts +23 -1
  51. package/src/lib/person-fields.ts +67 -0
  52. package/src/lib/print.ts +207 -0
  53. package/src/lib/tenant.ts +6 -2
  54. package/src/locales/en.ts +20 -0
  55. package/src/locales/pt-BR.ts +20 -0
  56. package/src/migrations/000_plg_rename.sql +21 -0
  57. package/src/migrations/001_frm_base.sql +92 -56
  58. package/src/migrations/002_document_archetype.sql +130 -42
  59. package/src/migrations/003_agent_rpcs.sql +174 -0
  60. package/src/migrations/index.ts +610 -0
  61. package/src/types.ts +18 -4
  62. package/src/views/CustomFormsSettingsTab.tsx +23 -16
  63. package/src/views/TemplateListView.tsx +29 -18
  64. package/dist/FormBuilder-4VKYVZAC.cjs.map +0 -1
  65. package/dist/FormBuilder-KDANEL6Z.js.map +0 -1
  66. package/dist/chunk-32I43T37.js +0 -198
  67. package/dist/chunk-32I43T37.js.map +0 -1
  68. package/dist/chunk-XLUULIVL.cjs +0 -200
  69. package/dist/chunk-XLUULIVL.cjs.map +0 -1
@@ -11,7 +11,7 @@ import {
11
11
  } from '@dnd-kit/core'
12
12
  import { arrayMove } from '@dnd-kit/sortable'
13
13
  import { snapCenterToCursor } from '@dnd-kit/modifiers'
14
- import { ArrowLeft, Eye } from 'lucide-react'
14
+ import { ArrowLeft, Eye, Pencil, Printer } from 'lucide-react'
15
15
  import { Button, useSaveBar, toast } from '@fayz-ai/ui'
16
16
  import { Input } from '@fayz-ai/ui'
17
17
  import { Card, CardContent } from '@fayz-ai/ui'
@@ -24,6 +24,7 @@ import {
24
24
  SelectValue,
25
25
  } from '@fayz-ai/ui'
26
26
  import { useTranslation } from '@fayz-ai/core'
27
+ import { useLimitGuard, invalidateLimit, useAgentSurface, type AgentSurfaceContribution } from '@fayz-ai/saas'
27
28
  import type { CustomFormsStore } from '../store'
28
29
  import type { CustomFormsDataProvider } from '../data/types'
29
30
  import type { CustomFormsConfig } from '../config'
@@ -33,6 +34,8 @@ import { BuilderCanvas } from './BuilderCanvas'
33
34
  import { FieldConfigDrawer } from './FieldConfigDialog'
34
35
  import { createPortal } from 'react-dom'
35
36
  import { FormRenderer } from './FormRenderer'
37
+ import { buildPrintHtml } from '../lib/print'
38
+ import { FORM_BUILDER_INSTRUCTIONS } from '../agent-tools'
36
39
 
37
40
  const TEMPLATE_CATEGORIES: TemplateCategory[] = ['anamnesis', 'evolution', 'report', 'contract', 'general']
38
41
 
@@ -46,6 +49,7 @@ interface FormBuilderProps {
46
49
 
47
50
  export function FormBuilder({ templateId, store, provider, config, onBack }: FormBuilderProps) {
48
51
  const t = useTranslation()
52
+ const guardTemplates = useLimitGuard('form_templates')
49
53
 
50
54
  const [name, setName] = useState('')
51
55
  const [description, setDescription] = useState('')
@@ -54,6 +58,7 @@ export function FormBuilder({ templateId, store, provider, config, onBack }: For
54
58
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null)
55
59
  const [saving, setSaving] = useState(false)
56
60
  const [showPreview, setShowPreview] = useState(false)
61
+ const [previewMode, setPreviewMode] = useState<'editor' | 'print'>('editor')
57
62
  const [previewData, setPreviewData] = useState<Record<string, unknown>>({})
58
63
  const [draggingFieldType, setDraggingFieldType] = useState<import('../types').FormFieldType | null>(null)
59
64
  const [templateName, setTemplateName] = useState<string | null>(null)
@@ -139,6 +144,48 @@ export function FormBuilder({ templateId, store, provider, config, onBack }: For
139
144
  setFields((prev) => prev.map((f) => (f.id === updated.id ? updated : f)))
140
145
  }, [])
141
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
+
142
189
  const handleFieldConfigSave = useCallback((updated: FormFieldDef) => {
143
190
  handleFieldUpdate(updated)
144
191
  setSelectedFieldId(null)
@@ -146,6 +193,9 @@ export function FormBuilder({ templateId, store, provider, config, onBack }: For
146
193
 
147
194
  const handleSave = useCallback(async () => {
148
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
149
199
  setSaving(true)
150
200
  try {
151
201
  const schema: FormSchema = { fields, layout: { columns: 12 } }
@@ -154,12 +204,13 @@ export function FormBuilder({ templateId, store, provider, config, onBack }: For
154
204
  await store.getState().updateTemplate(templateId, { name, description, category, schema })
155
205
  } else {
156
206
  await store.getState().createTemplate({ name, description, category, schema })
207
+ invalidateLimit('form_templates')
157
208
  }
158
209
  onBack()
159
210
  } finally {
160
211
  setSaving(false)
161
212
  }
162
- }, [templateId, name, description, category, fields, store, onBack])
213
+ }, [templateId, name, description, category, fields, store, onBack, guardTemplates])
163
214
 
164
215
  // Dirty detection — snapshot editable fields once async load settles
165
216
  const currentFields = { name, description, category, fields }
@@ -260,7 +311,7 @@ export function FormBuilder({ templateId, store, provider, config, onBack }: For
260
311
  size="sm"
261
312
  className="h-9"
262
313
  disabled={fields.length === 0}
263
- onClick={() => { setPreviewData({}); setShowPreview(true) }}
314
+ onClick={() => { setPreviewData({}); setPreviewMode('editor'); setShowPreview(true) }}
264
315
  >
265
316
  <Eye className="h-3.5 w-3.5 mr-1.5" />
266
317
  Preview
@@ -316,26 +367,57 @@ export function FormBuilder({ templateId, store, provider, config, onBack }: For
316
367
  style={{ minHeight: '80vh' }}
317
368
  onClick={(e) => e.stopPropagation()}
318
369
  >
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
- />
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>
338
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
+ )}
339
421
  </div>
340
422
  </div>,
341
423
  document.body,
@@ -1,11 +1,12 @@
1
1
  import React, { useEffect, useState } from 'react'
2
- import { ArrowLeft, Pencil, Badge as BadgeIcon } from 'lucide-react'
2
+ import { ArrowLeft, Pencil, Printer } from 'lucide-react'
3
3
  import { Button } from '@fayz-ai/ui'
4
4
  import { Badge } from '@fayz-ai/ui'
5
5
  import { useTranslation } from '@fayz-ai/core'
6
6
  import type { CustomFormsDataProvider } from '../data/types'
7
7
  import type { FormDocument, FormTemplate } from '../types'
8
8
  import { FormRenderer } from './FormRenderer'
9
+ import { printDocument } from '../lib/print'
9
10
 
10
11
  interface FormViewerProps {
11
12
  document: FormDocument
@@ -62,6 +63,15 @@ export function FormViewer({ document: doc, provider, onBack, onEdit }: FormView
62
63
  <Badge variant="secondary" className={`text-[10px] ${STATUS_COLORS[doc.status] ?? ''}`}>
63
64
  {t(`customForms.status.${doc.status}`)}
64
65
  </Badge>
66
+ <Button
67
+ variant="outline"
68
+ size="sm"
69
+ className="h-8"
70
+ onClick={() => printDocument(template, doc)}
71
+ >
72
+ <Printer className="h-3 w-3 mr-1" />
73
+ {t('customForms.printPdf')}
74
+ </Button>
65
75
  {onEdit && doc.status === 'draft' && (
66
76
  <Button variant="outline" size="sm" className="h-8" onClick={() => onEdit(doc)}>
67
77
  <Pencil className="h-3 w-3 mr-1" />
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef } from 'react'
2
2
  import { ArrowLeft, Image as ImageIcon, Paperclip } from 'lucide-react'
3
3
  import { Badge } from '@fayz-ai/ui'
4
4
  import { useTranslation } from '@fayz-ai/core'
5
+ import { useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
5
6
  import type { CustomFormsConfig } from '../config'
6
7
  import type { CustomFormsDataProvider } from '../data/types'
7
8
  import type { CustomFormsStore } from '../store'
@@ -27,6 +28,7 @@ type WidgetView =
27
28
 
28
29
  export function PersonDocumentsWidget({ item, config, provider, store }: PersonDocumentsWidgetProps) {
29
30
  const t = useTranslation()
31
+ const guardDocuments = useLimitGuard('documents_month')
30
32
  const [view, setView] = useState<WidgetView>({ type: 'list' })
31
33
  const fileInputRef = useRef<HTMLInputElement>(null)
32
34
  const [pendingFileAccept, setPendingFileAccept] = useState<string>('*/*')
@@ -58,6 +60,10 @@ export function PersonDocumentsWidget({ item, config, provider, store }: PersonD
58
60
  }, [provider])
59
61
 
60
62
  const handleFiles = useCallback(async (files: File[]) => {
63
+ // Plan monthly-quota guard (client-side, before the provider call) — count
64
+ // the whole batch. Opens the global UpgradeModal and aborts when the cap
65
+ // would be exceeded.
66
+ if ((await guardDocuments(files.length)) === 'blocked') return
61
67
  for (const file of files) {
62
68
  const isImage = file.type.startsWith('image/')
63
69
  await store.getState().createDocument({
@@ -70,7 +76,8 @@ export function PersonDocumentsWidget({ item, config, provider, store }: PersonD
70
76
  status: 'completed',
71
77
  })
72
78
  }
73
- }, [store, item.id])
79
+ invalidateLimit('documents_month')
80
+ }, [store, item.id, guardDocuments])
74
81
 
75
82
  const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
76
83
  const files = Array.from(e.target.files ?? [])
@@ -101,6 +108,7 @@ export function PersonDocumentsWidget({ item, config, provider, store }: PersonD
101
108
  template={view.template}
102
109
  personId={item.id}
103
110
  personName={item.name}
111
+ person={item}
104
112
  provider={provider}
105
113
  store={store}
106
114
  onSaved={handleSaved}
@@ -139,6 +147,7 @@ export function PersonDocumentsWidget({ item, config, provider, store }: PersonD
139
147
  existingDocument={view.document}
140
148
  personId={item.id}
141
149
  personName={item.name}
150
+ person={item}
142
151
  provider={provider}
143
152
  store={store}
144
153
  onSaved={handleSaved}
@@ -13,6 +13,7 @@ import type {
13
13
  UpdateDocumentInput,
14
14
  } from '../types'
15
15
  import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
16
+ import { T } from './tables'
16
17
 
17
18
  function getTenantId(): string {
18
19
  // Local override wins; else use the app's active tenant so writes pass RLS.
@@ -102,11 +103,13 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
102
103
  async getTemplates(query: TemplateQuery): Promise<PaginatedResult<FormTemplate>> {
103
104
  const db = getClient()
104
105
  let qb = db
105
- .from('frm_templates')
106
+ .from(T.templates)
106
107
  .select('*', { count: 'exact' })
107
108
  .eq('tenant_id', getTenantId())
108
109
  .eq('is_current', true)
109
110
  .eq('is_deleted', false)
111
+ // Archived templates (isActive=false) leave the list and the picker.
112
+ .eq('is_active', true)
110
113
 
111
114
  if (query.category) qb = qb.eq('category', query.category)
112
115
  if (query.search) qb = qb.ilike('name', `%${query.search}%`)
@@ -125,7 +128,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
125
128
  async getTemplateById(id: string): Promise<FormTemplate | null> {
126
129
  const db = getClient()
127
130
  const { data, error } = await db
128
- .from('frm_templates')
131
+ .from(T.templates)
129
132
  .select('*')
130
133
  .eq('id', id)
131
134
  .eq('is_deleted', false)
@@ -138,7 +141,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
138
141
  const db = getClient()
139
142
  const tenantId = getTenantId()
140
143
  const { data, error } = await db
141
- .from('frm_templates')
144
+ .from(T.templates)
142
145
  .insert({
143
146
  tenant_id: tenantId,
144
147
  name: input.name,
@@ -160,7 +163,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
160
163
  const tenantId = getTenantId()
161
164
 
162
165
  const { count } = await db
163
- .from('frm_documents')
166
+ .from(T.documents)
164
167
  .select('document_id', { count: 'exact', head: true })
165
168
  .eq('template_id', id)
166
169
 
@@ -171,7 +174,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
171
174
  if (!existing) throw new Error('Template not found')
172
175
 
173
176
  const { data: newRow, error: insertError } = await db
174
- .from('frm_templates')
177
+ .from(T.templates)
175
178
  .insert({
176
179
  tenant_id: tenantId,
177
180
  name: input.name ?? existing.name,
@@ -191,7 +194,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
191
194
  if (insertError) throw new Error(insertError.message)
192
195
 
193
196
  await db
194
- .from('frm_templates')
197
+ .from(T.templates)
195
198
  .update({ is_current: false, updated_at: new Date().toISOString() })
196
199
  .eq('id', id)
197
200
 
@@ -209,7 +212,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
209
212
  if (input.isActive !== undefined) updates.is_active = input.isActive
210
213
 
211
214
  const { data, error } = await db
212
- .from('frm_templates')
215
+ .from(T.templates)
213
216
  .update(updates)
214
217
  .eq('id', id)
215
218
  .select()
@@ -221,13 +224,13 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
221
224
  async deleteTemplate(id: string): Promise<void> {
222
225
  const db = getClient()
223
226
  const { error } = await db
224
- .from('frm_templates')
227
+ .from(T.templates)
225
228
  .update({ is_deleted: true, updated_at: new Date().toISOString() })
226
229
  .eq('id', id)
227
230
  if (error) throw new Error(error.message)
228
231
  },
229
232
 
230
- // ── Documents (saas_core.documents + frm_documents extension) ──
233
+ // ── Documents (public.documents + plg_forms_documents extension) ──
231
234
 
232
235
  async getDocuments(query: DocumentQuery): Promise<PaginatedResult<FormDocument>> {
233
236
  const db = getClient()
@@ -272,9 +275,9 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
272
275
  const tenantId = getTenantId()
273
276
  const isForm = !!input.templateId
274
277
 
275
- // Step 1: Insert into saas_core.documents
278
+ // Step 1: Insert into public.documents
276
279
  const { data: coreDoc, error: coreError } = await getClient()
277
- .from('frm_core_documents')
280
+ .from('documents')
278
281
  .insert({
279
282
  tenant_id: tenantId,
280
283
  kind: input.kind ?? (isForm ? 'form' : 'attachment'),
@@ -295,7 +298,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
295
298
  if (isForm && input.templateId) {
296
299
  const db = getClient()
297
300
  const { error: extError } = await db
298
- .from('frm_documents')
301
+ .from(T.documents)
299
302
  .insert({
300
303
  document_id: coreDoc.id,
301
304
  tenant_id: tenantId,
@@ -304,7 +307,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
304
307
  })
305
308
  if (extError) {
306
309
  // Rollback core insert
307
- await getClient().from('frm_core_documents').delete().eq('id', coreDoc.id)
310
+ await getClient().from('documents').delete().eq('id', coreDoc.id)
308
311
  throw new Error(extError.message)
309
312
  }
310
313
  }
@@ -324,7 +327,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
324
327
  if (input.notes !== undefined) coreUpdates.notes = input.notes
325
328
 
326
329
  const { error: coreError } = await getClient()
327
- .from('frm_core_documents')
330
+ .from('documents')
328
331
  .update(coreUpdates)
329
332
  .eq('id', id)
330
333
  if (coreError) throw new Error(coreError.message)
@@ -333,7 +336,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
333
336
  if (input.data !== undefined) {
334
337
  const db = getClient()
335
338
  await db
336
- .from('frm_documents')
339
+ .from(T.documents)
337
340
  .update({ data: input.data, updated_at: now })
338
341
  .eq('document_id', id)
339
342
  }
@@ -344,7 +347,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
344
347
 
345
348
  async deleteDocument(id: string): Promise<void> {
346
349
  const { error } = await getClient()
347
- .from('frm_core_documents')
350
+ .from('documents')
348
351
  .update({ is_active: false, updated_at: new Date().toISOString() })
349
352
  .eq('id', id)
350
353
  if (error) throw new Error(error.message)
@@ -355,7 +358,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
355
358
  async getDocumentFiles(documentId: string): Promise<DocumentFile[]> {
356
359
  const db = getClient()
357
360
  const { data, error } = await db
358
- .from('frm_document_files')
361
+ .from(T.documentFiles)
359
362
  .select('*')
360
363
  .eq('document_id', documentId)
361
364
  .order('sort_order')
@@ -377,7 +380,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
377
380
  const { data: urlData } = db.storage.from('documents').getPublicUrl(storagePath)
378
381
 
379
382
  const { data, error } = await db
380
- .from('frm_document_files')
383
+ .from(T.documentFiles)
381
384
  .insert({
382
385
  tenant_id: tenantId,
383
386
  document_id: documentId,
@@ -396,7 +399,7 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
396
399
  async deleteFile(fileId: string): Promise<void> {
397
400
  const db = getClient()
398
401
  const { error } = await db
399
- .from('frm_document_files')
402
+ .from(T.documentFiles)
400
403
  .delete()
401
404
  .eq('id', fileId)
402
405
  if (error) throw new Error(error.message)
@@ -0,0 +1,7 @@
1
+ // Central physical-table-name registry for plugin-forms. `documents` stays core.
2
+ export const T = {
3
+ documents: 'plg_forms_documents',
4
+ templates: 'plg_forms_templates',
5
+ documentFiles: 'plg_forms_document_files',
6
+ categories: 'plg_forms_categories',
7
+ } as const
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import React from 'react'
2
2
  import type { PluginManifest } from '@fayz-ai/core'
3
3
  import type { CustomFormsDataProvider } from './data/types'
4
+ import { T } from './data/tables'
4
5
  import { createMockFormsProvider } from './data/mock'
5
6
  import { createSupabaseFormsProvider } from './data/supabase'
6
7
  import { createSafeDataProvider, registerTranslations } from '@fayz-ai/core'
@@ -12,6 +13,7 @@ import type { CustomFormsPluginOptions } from './config'
12
13
  import { CustomFormsSettingsTab } from './views/CustomFormsSettingsTab'
13
14
  import { PersonDocumentsWidget as PersonDocumentsWidgetImpl } from './components/PersonDocumentsWidget'
14
15
  import { registerDocumentTypeProvider } from './document-types'
16
+ import { createOrUpdateFormTool } from './agent-tools'
15
17
  import { FileText as FileTextIcon, Image as ImageIcon, Paperclip } from 'lucide-react'
16
18
 
17
19
  function registerBuiltInProviders(formsProvider: CustomFormsDataProvider) {
@@ -82,7 +84,7 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
82
84
  { key: 'isActive', label: 'Active', type: 'boolean' as const, showInTable: true, defaultValue: true, inlineToggle: true },
83
85
  ],
84
86
  data: {
85
- table: 'frm_categories',
87
+ table: T.categories,
86
88
  tenantScoped: true,
87
89
  },
88
90
  },
@@ -104,6 +106,9 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
104
106
  React.createElement(PluginSettingsPanel, {
105
107
  title: config.labels.settingsLabel,
106
108
  subtitle: config.labels.settingsSubtitle,
109
+ // The settings shell already renders the section label as the page H1;
110
+ // hide the panel's own duplicate title (keeps the subtitle).
111
+ hideTitle: true,
107
112
  customTabs: [
108
113
  {
109
114
  id: 'forms',
@@ -134,6 +139,10 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
134
139
  declaredFeatures: [
135
140
  { id: 'custom_forms', label: config.labels.pageTitle, group: config.labels.pageTitle },
136
141
  ],
142
+ declaredLimits: [
143
+ { key: 'form_templates', label: 'Form templates', table: 'plg_forms_templates' },
144
+ { key: 'documents_month', label: 'Documents / month', table: 'documents', period: 'month' },
145
+ ],
137
146
 
138
147
  navigation: [],
139
148
 
@@ -205,6 +214,19 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
205
214
  ],
206
215
  permission: { feature: 'custom_forms', action: 'read' as const },
207
216
  },
217
+ // Server-plane write: builds/edits a form template via the pool RPC, so
218
+ // the assistant can montar formulários from any page or channel.
219
+ createOrUpdateFormTool,
220
+ ],
221
+
222
+ declaredRpcs: [
223
+ {
224
+ name: 'agent_forms_upsert_template',
225
+ kind: 'write' as const,
226
+ description:
227
+ 'Guarded form-template upsert: agent_guard (role→plan→form_templates cap), grid layout computed server-side, create or edit by id, audited.',
228
+ audits: true,
229
+ },
208
230
  ],
209
231
 
210
232
  locales: customFormsLocales,
@@ -0,0 +1,67 @@
1
+ // ============================================================
2
+ // PERSON FIELD MAPPING — prefill a document from a person record
3
+ // ============================================================
4
+ //
5
+ // A template field can declare `map: '<personKey>'` (see FormFieldDef.map).
6
+ // When a document is created from a person's profile, fields with a mapping are
7
+ // pre-filled from that person's record. The person record reaching the widget
8
+ // is the host app's CRUD detail `item`, keyed by the entity's field keys
9
+ // (camelCase, e.g. `documentNumber`). We resolve tolerantly so the same mapping
10
+ // works whether the host exposes camelCase or snake_case keys.
11
+
12
+ import type { FormSchema } from '../types'
13
+
14
+ /** Curated, vertical-agnostic person keys offered in the field builder. */
15
+ export const PERSON_FIELD_OPTIONS: Array<{ value: string; labelKey: string }> = [
16
+ { value: 'name', labelKey: 'customForms.personField.name' },
17
+ { value: 'documentNumber', labelKey: 'customForms.personField.documentNumber' },
18
+ { value: 'email', labelKey: 'customForms.personField.email' },
19
+ { value: 'phone', labelKey: 'customForms.personField.phone' },
20
+ { value: 'dateOfBirth', labelKey: 'customForms.personField.dateOfBirth' },
21
+ { value: 'guardianName', labelKey: 'customForms.personField.guardianName' },
22
+ { value: 'address', labelKey: 'customForms.personField.address' },
23
+ { value: 'city', labelKey: 'customForms.personField.city' },
24
+ { value: 'state', labelKey: 'customForms.personField.state' },
25
+ ]
26
+
27
+ const toSnake = (k: string) => k.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase())
28
+ const toCamel = (k: string) => k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
29
+
30
+ /** Read a person field value, tolerating camelCase/snake_case key variants. */
31
+ export function resolvePersonValue(
32
+ person: Record<string, unknown> | undefined | null,
33
+ mapKey: string | undefined,
34
+ ): unknown {
35
+ if (!person || !mapKey) return undefined
36
+ for (const key of [mapKey, toSnake(mapKey), toCamel(mapKey)]) {
37
+ const v = person[key]
38
+ if (v != null && v !== '') return v
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ /**
44
+ * Build the initial document data for a template. For each field, applies (in
45
+ * priority order) an existing value, then the mapped person value, then the
46
+ * field's static `defaultValue`. Never overwrites a value already present in
47
+ * `existing`.
48
+ */
49
+ export function prefillFromPerson(
50
+ schema: FormSchema | undefined,
51
+ person: Record<string, unknown> | undefined | null,
52
+ existing: Record<string, unknown> = {},
53
+ ): Record<string, unknown> {
54
+ const data: Record<string, unknown> = { ...existing }
55
+ if (!schema) return data
56
+ for (const field of schema.fields) {
57
+ const current = data[field.id]
58
+ if (current != null && current !== '') continue
59
+ const mapped = field.map ? resolvePersonValue(person, field.map) : undefined
60
+ if (mapped != null) {
61
+ data[field.id] = mapped
62
+ } else if (field.defaultValue != null) {
63
+ data[field.id] = field.defaultValue
64
+ }
65
+ }
66
+ return data
67
+ }