@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.
Files changed (62) hide show
  1. package/dist/{FormBuilder-KDANEL6Z.js → FormBuilder-7SWZQKFN.js} +130 -23
  2. package/dist/FormBuilder-7SWZQKFN.js.map +1 -0
  3. package/dist/{FormBuilder-4VKYVZAC.cjs → FormBuilder-YB75LEEH.cjs} +129 -22
  4. package/dist/FormBuilder-YB75LEEH.cjs.map +1 -0
  5. package/dist/agent-tools.d.ts +4 -0
  6. package/dist/agent-tools.d.ts.map +1 -0
  7. package/dist/chunk-QM2AF3DZ.cjs +446 -0
  8. package/dist/chunk-QM2AF3DZ.cjs.map +1 -0
  9. package/dist/chunk-W2ZNJGQC.js +438 -0
  10. package/dist/chunk-W2ZNJGQC.js.map +1 -0
  11. package/dist/components/DocumentFormDialog.d.ts +3 -1
  12. package/dist/components/DocumentFormDialog.d.ts.map +1 -1
  13. package/dist/components/DocumentList.d.ts.map +1 -1
  14. package/dist/components/FieldConfigDialog.d.ts.map +1 -1
  15. package/dist/components/FormBuilder.d.ts.map +1 -1
  16. package/dist/components/FormViewer.d.ts.map +1 -1
  17. package/dist/components/PersonDocumentsWidget.d.ts.map +1 -1
  18. package/dist/data/supabase.d.ts.map +1 -1
  19. package/dist/index.cjs +142 -54
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +142 -54
  23. package/dist/index.js.map +1 -1
  24. package/dist/lib/person-fields.d.ts +16 -0
  25. package/dist/lib/person-fields.d.ts.map +1 -0
  26. package/dist/lib/print.d.ts +17 -0
  27. package/dist/lib/print.d.ts.map +1 -0
  28. package/dist/lib/tenant.d.ts.map +1 -1
  29. package/dist/locales/en.d.ts.map +1 -1
  30. package/dist/locales/pt-BR.d.ts.map +1 -1
  31. package/dist/migrations/index.d.ts +1 -0
  32. package/dist/migrations/index.d.ts.map +1 -1
  33. package/dist/types.d.ts +14 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -1
  36. package/dist/views/TemplateListView.d.ts.map +1 -1
  37. package/package.json +4 -4
  38. package/src/agent-tools.ts +73 -0
  39. package/src/components/DocumentFormDialog.tsx +37 -16
  40. package/src/components/DocumentList.tsx +12 -8
  41. package/src/components/FieldConfigDialog.tsx +40 -0
  42. package/src/components/FormBuilder.tsx +104 -22
  43. package/src/components/FormViewer.tsx +11 -1
  44. package/src/components/PersonDocumentsWidget.tsx +10 -1
  45. package/src/data/supabase.ts +2 -0
  46. package/src/index.ts +21 -0
  47. package/src/lib/person-fields.ts +67 -0
  48. package/src/lib/print.ts +207 -0
  49. package/src/lib/tenant.ts +6 -2
  50. package/src/locales/en.ts +20 -0
  51. package/src/locales/pt-BR.ts +20 -0
  52. package/src/migrations/003_agent_rpcs.sql +174 -0
  53. package/src/migrations/index.ts +178 -1
  54. package/src/types.ts +14 -0
  55. package/src/views/CustomFormsSettingsTab.tsx +23 -16
  56. package/src/views/TemplateListView.tsx +29 -18
  57. package/dist/FormBuilder-4VKYVZAC.cjs.map +0 -1
  58. package/dist/FormBuilder-KDANEL6Z.js.map +0 -1
  59. package/dist/chunk-32I43T37.js +0 -198
  60. package/dist/chunk-32I43T37.js.map +0 -1
  61. package/dist/chunk-XLUULIVL.cjs +0 -200
  62. 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}
@@ -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
+ }
@@ -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
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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(' &middot; ')
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. Defaults to undefined Supabase RLS scopes by session.
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',