@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.
- package/README.md +2 -0
- package/dist/{FormBuilder-KDANEL6Z.js → FormBuilder-7SWZQKFN.js} +130 -23
- package/dist/FormBuilder-7SWZQKFN.js.map +1 -0
- package/dist/{FormBuilder-4VKYVZAC.cjs → FormBuilder-YB75LEEH.cjs} +129 -22
- package/dist/FormBuilder-YB75LEEH.cjs.map +1 -0
- package/dist/agent-tools.d.ts +4 -0
- package/dist/agent-tools.d.ts.map +1 -0
- package/dist/chunk-QM2AF3DZ.cjs +446 -0
- package/dist/chunk-QM2AF3DZ.cjs.map +1 -0
- package/dist/chunk-W2ZNJGQC.js +438 -0
- package/dist/chunk-W2ZNJGQC.js.map +1 -0
- package/dist/components/DocumentFormDialog.d.ts +3 -1
- package/dist/components/DocumentFormDialog.d.ts.map +1 -1
- package/dist/components/DocumentList.d.ts.map +1 -1
- package/dist/components/FieldConfigDialog.d.ts.map +1 -1
- package/dist/components/FormBuilder.d.ts.map +1 -1
- package/dist/components/FormViewer.d.ts.map +1 -1
- package/dist/components/PersonDocumentsWidget.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +7 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.cjs +168 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +168 -72
- package/dist/index.js.map +1 -1
- package/dist/lib/person-fields.d.ts +16 -0
- package/dist/lib/person-fields.d.ts.map +1 -0
- package/dist/lib/print.d.ts +17 -0
- package/dist/lib/print.d.ts.map +1 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +9 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -1
- package/dist/views/TemplateListView.d.ts.map +1 -1
- package/package.json +10 -4
- package/src/agent-tools.ts +73 -0
- package/src/components/DocumentFormDialog.tsx +37 -16
- package/src/components/DocumentList.tsx +12 -8
- package/src/components/FieldConfigDialog.tsx +40 -0
- package/src/components/FormBuilder.tsx +104 -22
- package/src/components/FormViewer.tsx +11 -1
- package/src/components/PersonDocumentsWidget.tsx +10 -1
- package/src/data/supabase.ts +22 -19
- package/src/data/tables.ts +7 -0
- package/src/index.ts +23 -1
- package/src/lib/person-fields.ts +67 -0
- package/src/lib/print.ts +207 -0
- package/src/lib/tenant.ts +6 -2
- package/src/locales/en.ts +20 -0
- package/src/locales/pt-BR.ts +20 -0
- package/src/migrations/000_plg_rename.sql +21 -0
- package/src/migrations/001_frm_base.sql +92 -56
- package/src/migrations/002_document_archetype.sql +130 -42
- package/src/migrations/003_agent_rpcs.sql +174 -0
- package/src/migrations/index.ts +610 -0
- package/src/types.ts +18 -4
- package/src/views/CustomFormsSettingsTab.tsx +23 -16
- package/src/views/TemplateListView.tsx +29 -18
- package/dist/FormBuilder-4VKYVZAC.cjs.map +0 -1
- package/dist/FormBuilder-KDANEL6Z.js.map +0 -1
- package/dist/chunk-32I43T37.js +0 -198
- package/dist/chunk-32I43T37.js.map +0 -1
- package/dist/chunk-XLUULIVL.cjs +0 -200
- 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
|
-
{/*
|
|
320
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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,
|
|
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
|
-
|
|
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}
|
package/src/data/supabase.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
|
278
|
+
// Step 1: Insert into public.documents
|
|
276
279
|
const { data: coreDoc, error: coreError } = await getClient()
|
|
277
|
-
.from('
|
|
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(
|
|
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('
|
|
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('
|
|
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(
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
+
}
|