@fayz-ai/plugin-forms 0.8.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/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/index.cjs +142 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +142 -54
- 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 +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/types.d.ts +14 -0
- 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 +4 -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 +2 -0
- package/src/index.ts +21 -0
- 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/003_agent_rpcs.sql +174 -0
- package/src/migrations/index.ts +178 -1
- package/src/types.ts +14 -0
- 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
|
@@ -108,6 +108,8 @@ export function createSupabaseFormsProvider(): CustomFormsDataProvider {
|
|
|
108
108
|
.eq('tenant_id', getTenantId())
|
|
109
109
|
.eq('is_current', true)
|
|
110
110
|
.eq('is_deleted', false)
|
|
111
|
+
// Archived templates (isActive=false) leave the list and the picker.
|
|
112
|
+
.eq('is_active', true)
|
|
111
113
|
|
|
112
114
|
if (query.category) qb = qb.eq('category', query.category)
|
|
113
115
|
if (query.search) qb = qb.ilike('name', `%${query.search}%`)
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { CustomFormsPluginOptions } from './config'
|
|
|
13
13
|
import { CustomFormsSettingsTab } from './views/CustomFormsSettingsTab'
|
|
14
14
|
import { PersonDocumentsWidget as PersonDocumentsWidgetImpl } from './components/PersonDocumentsWidget'
|
|
15
15
|
import { registerDocumentTypeProvider } from './document-types'
|
|
16
|
+
import { createOrUpdateFormTool } from './agent-tools'
|
|
16
17
|
import { FileText as FileTextIcon, Image as ImageIcon, Paperclip } from 'lucide-react'
|
|
17
18
|
|
|
18
19
|
function registerBuiltInProviders(formsProvider: CustomFormsDataProvider) {
|
|
@@ -105,6 +106,9 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
|
|
|
105
106
|
React.createElement(PluginSettingsPanel, {
|
|
106
107
|
title: config.labels.settingsLabel,
|
|
107
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,
|
|
108
112
|
customTabs: [
|
|
109
113
|
{
|
|
110
114
|
id: 'forms',
|
|
@@ -135,6 +139,10 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
|
|
|
135
139
|
declaredFeatures: [
|
|
136
140
|
{ id: 'custom_forms', label: config.labels.pageTitle, group: config.labels.pageTitle },
|
|
137
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
|
+
],
|
|
138
146
|
|
|
139
147
|
navigation: [],
|
|
140
148
|
|
|
@@ -206,6 +214,19 @@ export function createCustomFormsPlugin(options?: CustomFormsPluginOptions): Plu
|
|
|
206
214
|
],
|
|
207
215
|
permission: { feature: 'custom_forms', action: 'read' as const },
|
|
208
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
|
+
},
|
|
209
230
|
],
|
|
210
231
|
|
|
211
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
|
+
}
|
package/src/lib/print.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// PRINT / PDF — render a filled document to a printable A4 page
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Browser print-to-PDF: we build a clean, self-contained A4 HTML document from
|
|
6
|
+
// the template schema + saved data, drop it into a hidden iframe, and call
|
|
7
|
+
// print() on it. The browser's print dialog covers both physical printing and
|
|
8
|
+
// "Save as PDF". No external deps.
|
|
9
|
+
|
|
10
|
+
import type { FormDocument, FormFieldDef, FormTemplate } from '../types'
|
|
11
|
+
|
|
12
|
+
function esc(value: unknown): string {
|
|
13
|
+
return String(value ?? '').replace(/[&<>"']/g, (c) =>
|
|
14
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string),
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nl2br(value: string): string {
|
|
19
|
+
return esc(value).replace(/\r?\n/g, '<br/>')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDate(value: string): string {
|
|
23
|
+
// ISO date (YYYY-MM-DD) → locale date; anything else passes through.
|
|
24
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
|
|
25
|
+
if (!m) return esc(value)
|
|
26
|
+
return `${m[3]}/${m[2]}/${m[1]}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionLabel(field: FormFieldDef, value: unknown): string {
|
|
30
|
+
const opt = (field.options ?? []).find((o) => o.value === value)
|
|
31
|
+
return esc(opt ? opt.label : value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render one field's value as an HTML fragment for the printed page. */
|
|
35
|
+
function renderValue(field: FormFieldDef, value: unknown): string {
|
|
36
|
+
const empty = '<span class="empty">—</span>'
|
|
37
|
+
switch (field.type) {
|
|
38
|
+
case 'memo':
|
|
39
|
+
case 'richtext':
|
|
40
|
+
return value ? `<div class="long">${nl2br(String(value))}</div>` : empty
|
|
41
|
+
case 'date':
|
|
42
|
+
return value ? formatDate(String(value)) : empty
|
|
43
|
+
case 'select':
|
|
44
|
+
case 'radio':
|
|
45
|
+
return value ? optionLabel(field, value) : empty
|
|
46
|
+
case 'tags': {
|
|
47
|
+
const arr = Array.isArray(value) ? value : []
|
|
48
|
+
return arr.length
|
|
49
|
+
? arr.map((v) => optionLabel(field, v)).join(', ')
|
|
50
|
+
: empty
|
|
51
|
+
}
|
|
52
|
+
case 'checkbox':
|
|
53
|
+
return value ? 'Sim' : 'Não'
|
|
54
|
+
case 'image':
|
|
55
|
+
case 'gallery':
|
|
56
|
+
case 'budget':
|
|
57
|
+
return '' // not meaningful on a printed contract
|
|
58
|
+
default:
|
|
59
|
+
return value ? esc(value) : empty
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildPrintOptions {
|
|
64
|
+
/** Heading shown at the top of the page (defaults to the document title). */
|
|
65
|
+
heading?: string
|
|
66
|
+
/** Small line under the heading (e.g. person name). */
|
|
67
|
+
subheading?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a full, self-contained printable HTML document. */
|
|
71
|
+
export function buildPrintHtml(
|
|
72
|
+
template: FormTemplate,
|
|
73
|
+
doc: FormDocument,
|
|
74
|
+
opts: BuildPrintOptions = {},
|
|
75
|
+
): string {
|
|
76
|
+
const heading = opts.heading ?? doc.title ?? template.name
|
|
77
|
+
const subParts = [opts.subheading ?? doc.personName, new Date(doc.createdAt).toLocaleDateString()]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.map((s) => esc(s))
|
|
80
|
+
.join(' · ')
|
|
81
|
+
|
|
82
|
+
const fields = [...template.schema.fields].sort((a, b) => a.row - b.row || a.col - b.col)
|
|
83
|
+
|
|
84
|
+
const clampSpan = (n: unknown) => Math.max(1, Math.min(12, Number(n) || 12))
|
|
85
|
+
|
|
86
|
+
// Mirror the builder's 12-column grid so the print keeps the authored layout
|
|
87
|
+
// (side-by-side fields stay side by side). Titles span the full row.
|
|
88
|
+
const body = fields
|
|
89
|
+
.map((field) => {
|
|
90
|
+
if (field.type === 'title') {
|
|
91
|
+
return `<h2 class="section" style="grid-column: 1 / -1">${esc(field.label)}</h2>`
|
|
92
|
+
}
|
|
93
|
+
const rendered = renderValue(field, doc.data[field.id])
|
|
94
|
+
if (rendered === '') return ''
|
|
95
|
+
const isLong = field.type === 'memo' || field.type === 'richtext'
|
|
96
|
+
return `<div class="field${isLong ? ' field--long' : ''}" style="grid-column: span ${clampSpan(field.colSpan)}">
|
|
97
|
+
<div class="label">${esc(field.label)}</div>
|
|
98
|
+
<div class="value">${rendered}</div>
|
|
99
|
+
</div>`
|
|
100
|
+
})
|
|
101
|
+
.join('\n')
|
|
102
|
+
|
|
103
|
+
return `<!doctype html>
|
|
104
|
+
<html lang="pt-BR">
|
|
105
|
+
<head>
|
|
106
|
+
<meta charset="utf-8"/>
|
|
107
|
+
<title>${esc(heading)}</title>
|
|
108
|
+
<style>
|
|
109
|
+
@page { size: A4; margin: 20mm; }
|
|
110
|
+
* { box-sizing: border-box; }
|
|
111
|
+
html, body { margin: 0; padding: 0; }
|
|
112
|
+
body {
|
|
113
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
114
|
+
color: #111; font-size: 12pt; line-height: 1.5;
|
|
115
|
+
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
|
116
|
+
}
|
|
117
|
+
.page { max-width: 170mm; margin: 0 auto; padding: 12mm 0; }
|
|
118
|
+
header { border-bottom: 2px solid #111; padding-bottom: 8px; margin-bottom: 20px; }
|
|
119
|
+
header h1 { font-size: 18pt; margin: 0 0 2px; }
|
|
120
|
+
header .sub { font-size: 10pt; color: #555; }
|
|
121
|
+
/* 12-column grid — mirrors the builder Preview (FormRenderer) so the print
|
|
122
|
+
keeps the authored column layout. */
|
|
123
|
+
.grid { display: grid; grid-template-columns: repeat(12, 1fr); column-gap: 16px; row-gap: 12px; align-items: start; }
|
|
124
|
+
h2.section { font-size: 13pt; margin: 22px 0 4px; padding-bottom: 3px; border-bottom: 1px solid #ccc; }
|
|
125
|
+
.grid > h2.section:first-child { margin-top: 4px; }
|
|
126
|
+
.field { margin: 0; min-width: 0; }
|
|
127
|
+
.field .label { font-size: 9pt; text-transform: uppercase; letter-spacing: .04em; color: #666; margin-bottom: 2px; }
|
|
128
|
+
.field .value { font-size: 12pt; }
|
|
129
|
+
.field--long .value .long { white-space: normal; text-align: justify; }
|
|
130
|
+
.empty { color: #bbb; }
|
|
131
|
+
footer { margin-top: 40px; font-size: 9pt; color: #888; text-align: center; }
|
|
132
|
+
@media screen { body { background: #f3f3f3; } .page { background: #fff; max-width: 210mm; min-height: 297mm; padding: 20mm; box-shadow: 0 1px 8px rgba(0,0,0,.15); } }
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<div class="page">
|
|
137
|
+
<header>
|
|
138
|
+
<h1>${esc(heading)}</h1>
|
|
139
|
+
${subParts ? `<div class="sub">${subParts}</div>` : ''}
|
|
140
|
+
</header>
|
|
141
|
+
<div class="grid">
|
|
142
|
+
${body}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</body>
|
|
146
|
+
</html>`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Print a prebuilt HTML document via a hidden iframe (popup-blocker safe).
|
|
151
|
+
* The browser print dialog handles both printing and "Save as PDF".
|
|
152
|
+
*/
|
|
153
|
+
export function printHtml(html: string): void {
|
|
154
|
+
if (typeof document === 'undefined') return
|
|
155
|
+
const iframe = document.createElement('iframe')
|
|
156
|
+
iframe.setAttribute('aria-hidden', 'true')
|
|
157
|
+
iframe.style.position = 'fixed'
|
|
158
|
+
iframe.style.right = '0'
|
|
159
|
+
iframe.style.bottom = '0'
|
|
160
|
+
iframe.style.width = '0'
|
|
161
|
+
iframe.style.height = '0'
|
|
162
|
+
iframe.style.border = '0'
|
|
163
|
+
document.body.appendChild(iframe)
|
|
164
|
+
|
|
165
|
+
const cleanup = () => {
|
|
166
|
+
// Give the print dialog a beat before tearing the frame down.
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (iframe.parentNode) iframe.parentNode.removeChild(iframe)
|
|
169
|
+
}, 1000)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const win = iframe.contentWindow
|
|
173
|
+
const idoc = iframe.contentDocument || win?.document
|
|
174
|
+
if (!win || !idoc) {
|
|
175
|
+
cleanup()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
idoc.open()
|
|
180
|
+
idoc.write(html)
|
|
181
|
+
idoc.close()
|
|
182
|
+
|
|
183
|
+
const doPrint = () => {
|
|
184
|
+
try {
|
|
185
|
+
win.focus()
|
|
186
|
+
win.print()
|
|
187
|
+
} finally {
|
|
188
|
+
cleanup()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Wait for the frame document to be ready before printing.
|
|
193
|
+
if (idoc.readyState === 'complete') {
|
|
194
|
+
setTimeout(doPrint, 50)
|
|
195
|
+
} else {
|
|
196
|
+
iframe.addEventListener('load', () => setTimeout(doPrint, 50), { once: true })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Convenience: build + print a document in one call. */
|
|
201
|
+
export function printDocument(
|
|
202
|
+
template: FormTemplate,
|
|
203
|
+
doc: FormDocument,
|
|
204
|
+
opts?: BuildPrintOptions,
|
|
205
|
+
): void {
|
|
206
|
+
printHtml(buildPrintHtml(template, doc, opts))
|
|
207
|
+
}
|
package/src/lib/tenant.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Current tenant id holder (runtime DI), decoupling the plugin from saas-core's
|
|
2
|
-
// organization.store.
|
|
2
|
+
// organization.store. Falls back to the core active-tenant when the host has
|
|
3
|
+
// not injected one explicitly (mirrors plugin-crm's resolver), so the settings
|
|
4
|
+
// template list — gated on this id — loads without extra host wiring.
|
|
5
|
+
import { getActiveTenantId } from '@fayz-ai/core'
|
|
6
|
+
|
|
3
7
|
let currentTenantId: string | undefined
|
|
4
8
|
export function setFormsTenantId(id: string | undefined): void { currentTenantId = id }
|
|
5
|
-
export function getFormsTenantId(): string | undefined { return currentTenantId }
|
|
9
|
+
export function getFormsTenantId(): string | undefined { return currentTenantId ?? getActiveTenantId() }
|
package/src/locales/en.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const en: Record<string, string> = {
|
|
|
14
14
|
'customForms.noTemplates': 'No form templates yet',
|
|
15
15
|
'customForms.noTemplatesDescription': 'Create your first custom form to start collecting structured data.',
|
|
16
16
|
'customForms.deleteTemplateConfirm': 'Delete this form template? Existing documents will be preserved.',
|
|
17
|
+
'customForms.archiveTemplateConfirm': 'Archive this form template? It leaves the list and the document picker, but is kept and existing documents are preserved.',
|
|
17
18
|
|
|
18
19
|
// Categories
|
|
19
20
|
'customForms.category.anamnesis': 'Anamnesis',
|
|
@@ -37,6 +38,22 @@ export const en: Record<string, string> = {
|
|
|
37
38
|
'customForms.builder.addOption': 'Add option',
|
|
38
39
|
'customForms.builder.colSpan': 'Column span',
|
|
39
40
|
'customForms.builder.deleteField': 'Remove field',
|
|
41
|
+
'customForms.builder.mapToPerson': 'Auto-fill from record',
|
|
42
|
+
'customForms.builder.mapNone': "Don't auto-fill",
|
|
43
|
+
'customForms.builder.mapHint': "When creating the document from a person's profile, this field is pre-filled with the chosen value (still editable).",
|
|
44
|
+
'customForms.preview.editor': 'Editor',
|
|
45
|
+
'customForms.preview.print': 'Print',
|
|
46
|
+
|
|
47
|
+
// Person field mappings (auto-fill)
|
|
48
|
+
'customForms.personField.name': 'Name',
|
|
49
|
+
'customForms.personField.documentNumber': 'ID / Document',
|
|
50
|
+
'customForms.personField.email': 'Email',
|
|
51
|
+
'customForms.personField.phone': 'Phone',
|
|
52
|
+
'customForms.personField.dateOfBirth': 'Date of Birth',
|
|
53
|
+
'customForms.personField.guardianName': 'Guardian',
|
|
54
|
+
'customForms.personField.address': 'Address',
|
|
55
|
+
'customForms.personField.city': 'City',
|
|
56
|
+
'customForms.personField.state': 'State',
|
|
40
57
|
|
|
41
58
|
// Field types
|
|
42
59
|
'customForms.fieldType.title': 'Title',
|
|
@@ -62,6 +79,9 @@ export const en: Record<string, string> = {
|
|
|
62
79
|
'customForms.saveAsDraft': 'Save as Draft',
|
|
63
80
|
'customForms.saveAndComplete': 'Save & Complete',
|
|
64
81
|
'customForms.deleteDocumentConfirm': 'Delete this document?',
|
|
82
|
+
'customForms.archive': 'Archive',
|
|
83
|
+
'customForms.archiveDocumentConfirm': 'Archive this document? It leaves the active list but is kept on file.',
|
|
84
|
+
'customForms.printPdf': 'Print / PDF',
|
|
65
85
|
|
|
66
86
|
// Registry
|
|
67
87
|
'registry.form-categories': 'Categories',
|