@fayz-ai/plugin-forms 0.8.0 → 0.9.0-next.0

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 (59) 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/agent-tools.d.ts +4 -0
  4. package/dist/agent-tools.d.ts.map +1 -0
  5. package/dist/chunk-W2ZNJGQC.js +438 -0
  6. package/dist/chunk-W2ZNJGQC.js.map +1 -0
  7. package/dist/components/DocumentFormDialog.d.ts +3 -1
  8. package/dist/components/DocumentFormDialog.d.ts.map +1 -1
  9. package/dist/components/DocumentList.d.ts.map +1 -1
  10. package/dist/components/FieldConfigDialog.d.ts.map +1 -1
  11. package/dist/components/FormBuilder.d.ts.map +1 -1
  12. package/dist/components/FormViewer.d.ts.map +1 -1
  13. package/dist/components/PersonDocumentsWidget.d.ts.map +1 -1
  14. package/dist/data/supabase.d.ts.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +142 -54
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/person-fields.d.ts +16 -0
  19. package/dist/lib/person-fields.d.ts.map +1 -0
  20. package/dist/lib/print.d.ts +17 -0
  21. package/dist/lib/print.d.ts.map +1 -0
  22. package/dist/lib/tenant.d.ts.map +1 -1
  23. package/dist/locales/en.d.ts.map +1 -1
  24. package/dist/locales/pt-BR.d.ts.map +1 -1
  25. package/dist/migrations/index.d.ts +1 -0
  26. package/dist/migrations/index.d.ts.map +1 -1
  27. package/dist/types.d.ts +14 -0
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -1
  30. package/dist/views/TemplateListView.d.ts.map +1 -1
  31. package/package.json +13 -11
  32. package/src/agent-tools.ts +73 -0
  33. package/src/components/DocumentFormDialog.tsx +37 -16
  34. package/src/components/DocumentList.tsx +12 -8
  35. package/src/components/FieldConfigDialog.tsx +40 -0
  36. package/src/components/FormBuilder.tsx +104 -22
  37. package/src/components/FormViewer.tsx +11 -1
  38. package/src/components/PersonDocumentsWidget.tsx +10 -1
  39. package/src/data/supabase.ts +2 -0
  40. package/src/index.ts +21 -0
  41. package/src/lib/person-fields.ts +67 -0
  42. package/src/lib/print.ts +207 -0
  43. package/src/lib/tenant.ts +6 -2
  44. package/src/locales/en.ts +20 -0
  45. package/src/locales/pt-BR.ts +20 -0
  46. package/src/migrations/003_agent_rpcs.sql +174 -0
  47. package/src/migrations/index.ts +178 -1
  48. package/src/types.ts +14 -0
  49. package/src/views/CustomFormsSettingsTab.tsx +23 -16
  50. package/src/views/TemplateListView.tsx +29 -18
  51. package/dist/FormBuilder-4VKYVZAC.cjs +0 -619
  52. package/dist/FormBuilder-4VKYVZAC.cjs.map +0 -1
  53. package/dist/FormBuilder-KDANEL6Z.js.map +0 -1
  54. package/dist/chunk-32I43T37.js +0 -198
  55. package/dist/chunk-32I43T37.js.map +0 -1
  56. package/dist/chunk-XLUULIVL.cjs +0 -200
  57. package/dist/chunk-XLUULIVL.cjs.map +0 -1
  58. package/dist/index.cjs +0 -1629
  59. package/dist/index.cjs.map +0 -1
@@ -14,6 +14,7 @@ export const ptBR: Record<string, string> = {
14
14
  'customForms.noTemplates': 'Nenhum modelo criado',
15
15
  'customForms.noTemplatesDescription': 'Crie seu primeiro formulário personalizado para começar a coletar dados estruturados.',
16
16
  'customForms.deleteTemplateConfirm': 'Excluir este modelo de formulário? Documentos existentes serão preservados.',
17
+ 'customForms.archiveTemplateConfirm': 'Arquivar este modelo? Ele sai da lista e do seletor de documentos, mas fica guardado e os documentos existentes são preservados.',
17
18
 
18
19
  // Categories
19
20
  'customForms.category.anamnesis': 'Anamnese',
@@ -37,6 +38,22 @@ export const ptBR: Record<string, string> = {
37
38
  'customForms.builder.addOption': 'Adicionar opção',
38
39
  'customForms.builder.colSpan': 'Largura em colunas',
39
40
  'customForms.builder.deleteField': 'Remover campo',
41
+ 'customForms.builder.mapToPerson': 'Preencher com dado da ficha',
42
+ 'customForms.builder.mapNone': 'Não preencher',
43
+ 'customForms.builder.mapHint': 'Ao criar o documento na ficha da pessoa, este campo já vem preenchido com o dado escolhido (editável).',
44
+ 'customForms.preview.editor': 'Editor',
45
+ 'customForms.preview.print': 'Impressão',
46
+
47
+ // Person field mappings (auto-fill)
48
+ 'customForms.personField.name': 'Nome',
49
+ 'customForms.personField.documentNumber': 'CPF / Documento',
50
+ 'customForms.personField.email': 'E-mail',
51
+ 'customForms.personField.phone': 'Telefone',
52
+ 'customForms.personField.dateOfBirth': 'Data de Nascimento',
53
+ 'customForms.personField.guardianName': 'Responsável',
54
+ 'customForms.personField.address': 'Endereço',
55
+ 'customForms.personField.city': 'Cidade',
56
+ 'customForms.personField.state': 'Estado',
40
57
 
41
58
  // Field types
42
59
  'customForms.fieldType.title': 'Título',
@@ -62,6 +79,9 @@ export const ptBR: Record<string, string> = {
62
79
  'customForms.saveAsDraft': 'Salvar Rascunho',
63
80
  'customForms.saveAndComplete': 'Salvar e Finalizar',
64
81
  'customForms.deleteDocumentConfirm': 'Excluir este documento?',
82
+ 'customForms.archive': 'Arquivar',
83
+ 'customForms.archiveDocumentConfirm': 'Arquivar este documento? Ele sai da lista ativa mas fica guardado.',
84
+ 'customForms.printPdf': 'Imprimir / PDF',
65
85
 
66
86
  // Registry
67
87
  'registry.form-categories': 'Categorias',
@@ -0,0 +1,174 @@
1
+ -- ============================================================================
2
+ -- plugin-forms 003: server-plane agent write RPC.
3
+ --
4
+ -- public.agent_forms_upsert_template — the assistant BUILDS or EDITS a form
5
+ -- template from any surface or channel (in-app FAB, WhatsApp, MCP). Same
6
+ -- contract as every agent_* RPC (agenda 005, financial 009):
7
+ -- (p_tenant_id, p_actor_user_id, p_payload jsonb) → jsonb
8
+ -- {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false, error}
9
+ --
10
+ -- The grid layout is computed HERE (col 0, sequential rows, full-width span) so
11
+ -- the model only ever sends {type,label,required?,options?} — never positions.
12
+ --
13
+ -- Payload:
14
+ -- template_id? uuid — omit to CREATE, provide to EDIT
15
+ -- name? text — required on create
16
+ -- category? text — anamnesis|evolution|report|contract|general
17
+ -- description? text
18
+ -- fields? [{type,label,required?,placeholder?,options?[]}] — REPLACE all
19
+ -- append_fields? [{...}] — APPEND to existing
20
+ --
21
+ -- GRANT authenticated+service_role — NEVER anon (the broker calls with the pool
22
+ -- service key and injects tenant/actor; in-browser the signed-in user calls it
23
+ -- directly and spine RLS/actor guards bind them to their own tenant).
24
+ -- ============================================================================
25
+
26
+ CREATE OR REPLACE FUNCTION public.agent_forms_upsert_template(
27
+ p_tenant_id uuid,
28
+ p_actor_user_id uuid,
29
+ p_payload jsonb
30
+ ) RETURNS jsonb
31
+ LANGUAGE plpgsql SECURITY DEFINER
32
+ SET search_path = public
33
+ AS $$
34
+ DECLARE
35
+ v_denial jsonb;
36
+ v_used int;
37
+ v_template_id uuid;
38
+ v_is_update boolean;
39
+ v_name text;
40
+ v_category text;
41
+ v_description text;
42
+ v_existing jsonb;
43
+ v_input jsonb;
44
+ v_merged jsonb;
45
+ v_fields jsonb;
46
+ v_schema jsonb;
47
+ v_final_id uuid;
48
+ BEGIN
49
+ -- ── payload ──────────────────────────────────────────────────────────────
50
+ BEGIN
51
+ v_template_id := NULLIF(p_payload->>'template_id','')::uuid;
52
+ EXCEPTION WHEN OTHERS THEN
53
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid template_id');
54
+ END;
55
+ v_is_update := v_template_id IS NOT NULL;
56
+ v_name := btrim(p_payload->>'name');
57
+ v_category := lower(COALESCE(NULLIF(p_payload->>'category',''), 'general'));
58
+ v_description := left(p_payload->>'description', 1000);
59
+ IF v_category NOT IN ('anamnesis','evolution','report','contract','general') THEN
60
+ v_category := 'general';
61
+ END IF;
62
+
63
+ IF NOT v_is_update AND (v_name IS NULL OR v_name = '') THEN
64
+ RETURN jsonb_build_object('ok', false, 'error', 'name is required to create a form');
65
+ END IF;
66
+
67
+ -- ── authorization: role → plan → form_templates cap ──────────────────────
68
+ SELECT count(*) INTO v_used FROM plg_forms_templates
69
+ WHERE tenant_id = p_tenant_id AND is_deleted = false;
70
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'custom_forms',
71
+ CASE WHEN v_is_update THEN 'update' ELSE 'create' END,
72
+ 'form_templates', v_used, CASE WHEN v_is_update THEN 0 ELSE 1 END);
73
+ IF v_denial IS NOT NULL THEN
74
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
75
+ END IF;
76
+
77
+ -- ── current fields (for APPEND / to reject an unknown id) ────────────────
78
+ IF v_is_update THEN
79
+ SELECT COALESCE(schema->'fields','[]'::jsonb) INTO v_existing
80
+ FROM plg_forms_templates
81
+ WHERE id = v_template_id AND tenant_id = p_tenant_id AND is_deleted = false;
82
+ IF v_existing IS NULL THEN
83
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown template for this tenant');
84
+ END IF;
85
+ ELSE
86
+ v_existing := '[]'::jsonb;
87
+ END IF;
88
+
89
+ -- ── normalize the incoming fields to the builder's FormFieldDef shape ────
90
+ -- (id, type, label, required, placeholder, col, colSpan, options) — row is
91
+ -- assigned last, across the merged list.
92
+ WITH src AS (
93
+ SELECT CASE
94
+ WHEN p_payload ? 'fields' THEN COALESCE(p_payload->'fields','[]'::jsonb)
95
+ WHEN p_payload ? 'append_fields' THEN COALESCE(p_payload->'append_fields','[]'::jsonb)
96
+ ELSE '[]'::jsonb
97
+ END AS arr
98
+ ),
99
+ norm AS (
100
+ SELECT f.ord, jsonb_strip_nulls(jsonb_build_object(
101
+ 'id', COALESCE(NULLIF(f.value->>'id',''), gen_random_uuid()::text),
102
+ 'type', COALESCE(NULLIF(f.value->>'type',''), 'text'),
103
+ 'label', COALESCE(NULLIF(f.value->>'label',''), 'Campo'),
104
+ 'required', COALESCE((f.value->>'required')::boolean, false),
105
+ 'placeholder', NULLIF(f.value->>'placeholder',''),
106
+ 'col', 0,
107
+ 'colSpan', 12,
108
+ 'options', CASE
109
+ WHEN jsonb_typeof(f.value->'options') = 'array' AND jsonb_array_length(f.value->'options') > 0
110
+ THEN (SELECT jsonb_agg(jsonb_build_object(
111
+ 'label', opt,
112
+ 'value', lower(regexp_replace(btrim(opt), '\s+', '_', 'g'))))
113
+ FROM jsonb_array_elements_text(f.value->'options') opt)
114
+ ELSE NULL END
115
+ )) AS field
116
+ FROM src, jsonb_array_elements(src.arr) WITH ORDINALITY AS f(value, ord)
117
+ )
118
+ SELECT COALESCE(jsonb_agg(field ORDER BY ord), '[]'::jsonb) INTO v_input FROM norm;
119
+
120
+ -- REPLACE with `fields`; otherwise APPEND onto what's already there.
121
+ IF p_payload ? 'fields' THEN
122
+ v_merged := v_input;
123
+ ELSE
124
+ v_merged := v_existing || v_input;
125
+ END IF;
126
+
127
+ -- sequential rows across the final list
128
+ SELECT COALESCE(jsonb_agg(jsonb_set(e.value, '{row}', to_jsonb((e.ord - 1))) ORDER BY e.ord), '[]'::jsonb)
129
+ INTO v_fields
130
+ FROM jsonb_array_elements(v_merged) WITH ORDINALITY AS e(value, ord);
131
+
132
+ v_schema := jsonb_build_object('layout', jsonb_build_object('columns', 12), 'fields', v_fields);
133
+
134
+ -- ── write + audit ────────────────────────────────────────────────────────
135
+ IF v_is_update THEN
136
+ UPDATE plg_forms_templates SET
137
+ name = COALESCE(NULLIF(v_name,''), name),
138
+ category = v_category,
139
+ description = COALESCE(v_description, description),
140
+ schema = v_schema,
141
+ updated_by = p_actor_user_id,
142
+ updated_at = now()
143
+ WHERE id = v_template_id AND tenant_id = p_tenant_id AND is_deleted = false
144
+ RETURNING id INTO v_final_id;
145
+ ELSE
146
+ INSERT INTO plg_forms_templates (tenant_id, name, category, description, schema, created_by, updated_by)
147
+ VALUES (p_tenant_id, v_name, v_category, v_description, v_schema, p_actor_user_id, p_actor_user_id)
148
+ RETURNING id INTO v_final_id;
149
+ END IF;
150
+
151
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
152
+ VALUES (p_tenant_id, p_actor_user_id,
153
+ CASE WHEN v_is_update THEN 'agent.updateFormTemplate' ELSE 'agent.createFormTemplate' END,
154
+ 'form_template', v_final_id::text,
155
+ jsonb_build_object('payload', p_payload));
156
+
157
+ RETURN jsonb_build_object(
158
+ 'ok', true,
159
+ 'id', v_final_id,
160
+ 'record', jsonb_build_object(
161
+ 'ref', jsonb_build_object('id', v_final_id, 'resource', 'plg_forms_templates',
162
+ 'archetype', 'custom_forms:template'),
163
+ 'name', COALESCE(NULLIF(v_name,''), (SELECT name FROM plg_forms_templates WHERE id = v_final_id)),
164
+ 'category', v_category,
165
+ 'fieldCount', jsonb_array_length(v_fields)
166
+ )
167
+ );
168
+ END;
169
+ $$;
170
+
171
+ REVOKE ALL ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb) FROM public;
172
+ REVOKE EXECUTE ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb) FROM anon;
173
+ GRANT EXECUTE ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb)
174
+ TO authenticated, service_role;
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000_plg_rename.sql, 001_frm_base.sql, 002_document_archetype.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_frm_base.sql, 002_document_archetype.sql, 003_agent_rpcs.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -426,8 +426,185 @@ LEFT JOIN public.plg_forms_templates t ON t.id = f.template_id
426
426
  LEFT JOIN public.people p ON p.id = d.person_id;
427
427
  `
428
428
 
429
+ export const MIGRATION_003_AGENT_RPCS = `-- ============================================================================
430
+ -- plugin-forms 003: server-plane agent write RPC.
431
+ --
432
+ -- public.agent_forms_upsert_template — the assistant BUILDS or EDITS a form
433
+ -- template from any surface or channel (in-app FAB, WhatsApp, MCP). Same
434
+ -- contract as every agent_* RPC (agenda 005, financial 009):
435
+ -- (p_tenant_id, p_actor_user_id, p_payload jsonb) → jsonb
436
+ -- {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false, error}
437
+ --
438
+ -- The grid layout is computed HERE (col 0, sequential rows, full-width span) so
439
+ -- the model only ever sends {type,label,required?,options?} — never positions.
440
+ --
441
+ -- Payload:
442
+ -- template_id? uuid — omit to CREATE, provide to EDIT
443
+ -- name? text — required on create
444
+ -- category? text — anamnesis|evolution|report|contract|general
445
+ -- description? text
446
+ -- fields? [{type,label,required?,placeholder?,options?[]}] — REPLACE all
447
+ -- append_fields? [{...}] — APPEND to existing
448
+ --
449
+ -- GRANT authenticated+service_role — NEVER anon (the broker calls with the pool
450
+ -- service key and injects tenant/actor; in-browser the signed-in user calls it
451
+ -- directly and spine RLS/actor guards bind them to their own tenant).
452
+ -- ============================================================================
453
+
454
+ CREATE OR REPLACE FUNCTION public.agent_forms_upsert_template(
455
+ p_tenant_id uuid,
456
+ p_actor_user_id uuid,
457
+ p_payload jsonb
458
+ ) RETURNS jsonb
459
+ LANGUAGE plpgsql SECURITY DEFINER
460
+ SET search_path = public
461
+ AS $$
462
+ DECLARE
463
+ v_denial jsonb;
464
+ v_used int;
465
+ v_template_id uuid;
466
+ v_is_update boolean;
467
+ v_name text;
468
+ v_category text;
469
+ v_description text;
470
+ v_existing jsonb;
471
+ v_input jsonb;
472
+ v_merged jsonb;
473
+ v_fields jsonb;
474
+ v_schema jsonb;
475
+ v_final_id uuid;
476
+ BEGIN
477
+ -- ── payload ──────────────────────────────────────────────────────────────
478
+ BEGIN
479
+ v_template_id := NULLIF(p_payload->>'template_id','')::uuid;
480
+ EXCEPTION WHEN OTHERS THEN
481
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid template_id');
482
+ END;
483
+ v_is_update := v_template_id IS NOT NULL;
484
+ v_name := btrim(p_payload->>'name');
485
+ v_category := lower(COALESCE(NULLIF(p_payload->>'category',''), 'general'));
486
+ v_description := left(p_payload->>'description', 1000);
487
+ IF v_category NOT IN ('anamnesis','evolution','report','contract','general') THEN
488
+ v_category := 'general';
489
+ END IF;
490
+
491
+ IF NOT v_is_update AND (v_name IS NULL OR v_name = '') THEN
492
+ RETURN jsonb_build_object('ok', false, 'error', 'name is required to create a form');
493
+ END IF;
494
+
495
+ -- ── authorization: role → plan → form_templates cap ──────────────────────
496
+ SELECT count(*) INTO v_used FROM plg_forms_templates
497
+ WHERE tenant_id = p_tenant_id AND is_deleted = false;
498
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'custom_forms',
499
+ CASE WHEN v_is_update THEN 'update' ELSE 'create' END,
500
+ 'form_templates', v_used, CASE WHEN v_is_update THEN 0 ELSE 1 END);
501
+ IF v_denial IS NOT NULL THEN
502
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
503
+ END IF;
504
+
505
+ -- ── current fields (for APPEND / to reject an unknown id) ────────────────
506
+ IF v_is_update THEN
507
+ SELECT COALESCE(schema->'fields','[]'::jsonb) INTO v_existing
508
+ FROM plg_forms_templates
509
+ WHERE id = v_template_id AND tenant_id = p_tenant_id AND is_deleted = false;
510
+ IF v_existing IS NULL THEN
511
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown template for this tenant');
512
+ END IF;
513
+ ELSE
514
+ v_existing := '[]'::jsonb;
515
+ END IF;
516
+
517
+ -- ── normalize the incoming fields to the builder's FormFieldDef shape ────
518
+ -- (id, type, label, required, placeholder, col, colSpan, options) — row is
519
+ -- assigned last, across the merged list.
520
+ WITH src AS (
521
+ SELECT CASE
522
+ WHEN p_payload ? 'fields' THEN COALESCE(p_payload->'fields','[]'::jsonb)
523
+ WHEN p_payload ? 'append_fields' THEN COALESCE(p_payload->'append_fields','[]'::jsonb)
524
+ ELSE '[]'::jsonb
525
+ END AS arr
526
+ ),
527
+ norm AS (
528
+ SELECT f.ord, jsonb_strip_nulls(jsonb_build_object(
529
+ 'id', COALESCE(NULLIF(f.value->>'id',''), gen_random_uuid()::text),
530
+ 'type', COALESCE(NULLIF(f.value->>'type',''), 'text'),
531
+ 'label', COALESCE(NULLIF(f.value->>'label',''), 'Campo'),
532
+ 'required', COALESCE((f.value->>'required')::boolean, false),
533
+ 'placeholder', NULLIF(f.value->>'placeholder',''),
534
+ 'col', 0,
535
+ 'colSpan', 12,
536
+ 'options', CASE
537
+ WHEN jsonb_typeof(f.value->'options') = 'array' AND jsonb_array_length(f.value->'options') > 0
538
+ THEN (SELECT jsonb_agg(jsonb_build_object(
539
+ 'label', opt,
540
+ 'value', lower(regexp_replace(btrim(opt), '\\s+', '_', 'g'))))
541
+ FROM jsonb_array_elements_text(f.value->'options') opt)
542
+ ELSE NULL END
543
+ )) AS field
544
+ FROM src, jsonb_array_elements(src.arr) WITH ORDINALITY AS f(value, ord)
545
+ )
546
+ SELECT COALESCE(jsonb_agg(field ORDER BY ord), '[]'::jsonb) INTO v_input FROM norm;
547
+
548
+ -- REPLACE with \`fields\`; otherwise APPEND onto what's already there.
549
+ IF p_payload ? 'fields' THEN
550
+ v_merged := v_input;
551
+ ELSE
552
+ v_merged := v_existing || v_input;
553
+ END IF;
554
+
555
+ -- sequential rows across the final list
556
+ SELECT COALESCE(jsonb_agg(jsonb_set(e.value, '{row}', to_jsonb((e.ord - 1))) ORDER BY e.ord), '[]'::jsonb)
557
+ INTO v_fields
558
+ FROM jsonb_array_elements(v_merged) WITH ORDINALITY AS e(value, ord);
559
+
560
+ v_schema := jsonb_build_object('layout', jsonb_build_object('columns', 12), 'fields', v_fields);
561
+
562
+ -- ── write + audit ────────────────────────────────────────────────────────
563
+ IF v_is_update THEN
564
+ UPDATE plg_forms_templates SET
565
+ name = COALESCE(NULLIF(v_name,''), name),
566
+ category = v_category,
567
+ description = COALESCE(v_description, description),
568
+ schema = v_schema,
569
+ updated_by = p_actor_user_id,
570
+ updated_at = now()
571
+ WHERE id = v_template_id AND tenant_id = p_tenant_id AND is_deleted = false
572
+ RETURNING id INTO v_final_id;
573
+ ELSE
574
+ INSERT INTO plg_forms_templates (tenant_id, name, category, description, schema, created_by, updated_by)
575
+ VALUES (p_tenant_id, v_name, v_category, v_description, v_schema, p_actor_user_id, p_actor_user_id)
576
+ RETURNING id INTO v_final_id;
577
+ END IF;
578
+
579
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
580
+ VALUES (p_tenant_id, p_actor_user_id,
581
+ CASE WHEN v_is_update THEN 'agent.updateFormTemplate' ELSE 'agent.createFormTemplate' END,
582
+ 'form_template', v_final_id::text,
583
+ jsonb_build_object('payload', p_payload));
584
+
585
+ RETURN jsonb_build_object(
586
+ 'ok', true,
587
+ 'id', v_final_id,
588
+ 'record', jsonb_build_object(
589
+ 'ref', jsonb_build_object('id', v_final_id, 'resource', 'plg_forms_templates',
590
+ 'archetype', 'custom_forms:template'),
591
+ 'name', COALESCE(NULLIF(v_name,''), (SELECT name FROM plg_forms_templates WHERE id = v_final_id)),
592
+ 'category', v_category,
593
+ 'fieldCount', jsonb_array_length(v_fields)
594
+ )
595
+ );
596
+ END;
597
+ $$;
598
+
599
+ REVOKE ALL ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb) FROM public;
600
+ REVOKE EXECUTE ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb) FROM anon;
601
+ GRANT EXECUTE ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb)
602
+ TO authenticated, service_role;
603
+ `
604
+
429
605
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
430
606
  { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
431
607
  { id: "001_frm_base", sql: MIGRATION_001_FRM_BASE },
432
608
  { id: "002_document_archetype", sql: MIGRATION_002_DOCUMENT_ARCHETYPE },
609
+ { id: "003_agent_rpcs", sql: MIGRATION_003_AGENT_RPCS },
433
610
  ]
package/src/types.ts CHANGED
@@ -36,6 +36,20 @@ export interface FormFieldDef {
36
36
  rowSpan?: number
37
37
  /** Options for select / radio / tags */
38
38
  options?: Array<{ label: string; value: string }>
39
+ /**
40
+ * Optional binding to a field on the person/contact record. When set, a new
41
+ * document created from a person's profile is pre-filled with that person's
42
+ * value (e.g. `map: 'documentNumber'` fills the field with the person's CPF).
43
+ * Matched tolerantly against camelCase/snake_case record keys. Only prefills
44
+ * empty fields; the user can still edit the value. See `lib/person-fields`.
45
+ */
46
+ map?: string
47
+ /**
48
+ * Static default value applied to a NEW document when the field has no mapped
49
+ * person value and no existing value — e.g. pre-written contract clause text
50
+ * on a richtext field. Editable by the user.
51
+ */
52
+ defaultValue?: unknown
39
53
  /** Type-specific configuration */
40
54
  config?: Record<string, unknown>
41
55
  }
@@ -11,35 +11,42 @@ interface CustomFormsSettingsTabProps {
11
11
  store: CustomFormsStore
12
12
  }
13
13
 
14
+ const ROUTE_BASE = '/settings/custom_forms'
15
+
16
+ /** Read the currently-open template id from the URL hash (the source of truth,
17
+ * so deep links and browser back/forward work like any CRUD detail page). */
18
+ function readBuilderIdFromHash(): string | null {
19
+ const hash = window.location.hash.slice(1) || '/'
20
+ const m = hash.match(/\/settings\/custom_forms\/templates\/([^/]+)/)
21
+ if (!m) return null
22
+ return m[1] === 'new' ? '__new__' : m[1]
23
+ }
24
+
14
25
  export function CustomFormsSettingsTab({ config, provider, store }: CustomFormsSettingsTabProps) {
15
- const [builderTemplateId, setBuilderTemplateId] = useState<string | null>(null)
26
+ // URL is the source of truth — opening/closing a template updates the hash,
27
+ // so the record id shows in the URL just like opening a CRUD record.
28
+ const [builderTemplateId, setBuilderTemplateId] = useState<string | null>(readBuilderIdFromHash)
16
29
 
17
30
  const handleNew = useCallback(() => {
18
- setBuilderTemplateId('__new__')
31
+ window.location.hash = `#${ROUTE_BASE}/templates/new`
19
32
  }, [])
20
33
 
21
34
  const handleEdit = useCallback((template: FormTemplate) => {
22
- setBuilderTemplateId(template.id)
35
+ window.location.hash = `#${ROUTE_BASE}/templates/${template.id}`
23
36
  }, [])
24
37
 
25
38
  const handleBackFromBuilder = useCallback(() => {
26
- setBuilderTemplateId(null)
27
- // Re-fetch templates to reflect any saves
39
+ // Return to the list route and re-fetch templates to reflect any saves.
40
+ window.location.hash = `#${ROUTE_BASE}/forms`
28
41
  store.getState().fetchTemplates()
29
42
  }, [store])
30
43
 
31
- // Sync from hash on mount
44
+ // Keep local state in sync with the URL (deep links, back/forward, and our
45
+ // own hash writes above all flow through here).
32
46
  React.useEffect(() => {
33
- function checkHash() {
34
- const hash = window.location.hash.slice(1) || '/'
35
- const builderMatch = hash.match(/\/settings\/custom_forms\/templates\/(.+)/)
36
- if (builderMatch && builderMatch[1] !== 'new') {
37
- setBuilderTemplateId(builderMatch[1])
38
- } else if (builderMatch && builderMatch[1] === 'new') {
39
- setBuilderTemplateId('__new__')
40
- }
41
- }
42
- checkHash()
47
+ const sync = () => setBuilderTemplateId(readBuilderIdFromHash())
48
+ window.addEventListener('hashchange', sync)
49
+ return () => window.removeEventListener('hashchange', sync)
43
50
  }, [])
44
51
 
45
52
  return builderTemplateId ? (
@@ -1,9 +1,10 @@
1
1
  import React, { useEffect, useState, useCallback } from 'react'
2
- import { Plus, Pencil, Trash2, FileText, Search } from 'lucide-react'
2
+ import { Plus, Pencil, Archive, FileText, Search } from 'lucide-react'
3
3
  import { Button } from '@fayz-ai/ui'
4
4
  import { Card, CardContent } from '@fayz-ai/ui'
5
5
  import { Badge } from '@fayz-ai/ui'
6
6
  import { Input } from '@fayz-ai/ui'
7
+ import { PermissionGate } from '@fayz-ai/saas'
7
8
  import { useTranslation } from '@fayz-ai/core'
8
9
  import { getFormsTenantId } from '../lib/tenant'
9
10
  import type { CustomFormsStore } from '../store'
@@ -44,11 +45,16 @@ export function TemplateListView({ store, config, onEdit, onNew }: TemplateListV
44
45
  return () => clearTimeout(timer)
45
46
  }, [orgId, search])
46
47
 
47
- const handleDelete = useCallback(async (template: FormTemplate) => {
48
- if (!window.confirm(t('customForms.deleteTemplateConfirm'))) return
49
- await store.getState().deleteTemplate(template.id)
48
+ // Archive, not delete: deactivate the template so it drops out of the list
49
+ // and the "add document" picker, without destroying it or its documents.
50
+ const handleArchive = useCallback(async (template: FormTemplate) => {
51
+ if (!window.confirm(t('customForms.archiveTemplateConfirm'))) return
52
+ await store.getState().updateTemplate(template.id, { isActive: false })
50
53
  }, [store, t])
51
54
 
55
+ // Archived (isActive=false) templates stay in the store but leave the view.
56
+ const visibleTemplates = templates.filter((tpl) => tpl.isActive !== false)
57
+
52
58
  const categoryLabel = (cat: TemplateCategory) =>
53
59
  t(`customForms.category.${cat}`)
54
60
 
@@ -65,14 +71,16 @@ export function TemplateListView({ store, config, onEdit, onNew }: TemplateListV
65
71
  className="h-9 pl-8 text-sm"
66
72
  />
67
73
  </div>
68
- <Button onClick={onNew} size="sm" className="h-9">
69
- <Plus className="h-3.5 w-3.5 mr-1.5" />
70
- {t('customForms.newTemplate')}
71
- </Button>
74
+ <PermissionGate feature="custom_forms" action="create">
75
+ <Button onClick={onNew} size="sm" className="h-9">
76
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
77
+ {t('customForms.newTemplate')}
78
+ </Button>
79
+ </PermissionGate>
72
80
  </div>
73
81
 
74
82
  {/* Empty state */}
75
- {!loading && templates.length === 0 && (
83
+ {!loading && visibleTemplates.length === 0 && (
76
84
  <Card>
77
85
  <CardContent className="flex flex-col items-center justify-center py-16 text-center">
78
86
  <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4">
@@ -82,18 +90,20 @@ export function TemplateListView({ store, config, onEdit, onNew }: TemplateListV
82
90
  <p className="text-sm text-muted-foreground mt-1 max-w-sm">
83
91
  {t('customForms.noTemplatesDescription')}
84
92
  </p>
85
- <Button onClick={onNew} size="sm" className="mt-4">
86
- <Plus className="h-3.5 w-3.5 mr-1.5" />
87
- {t('customForms.newTemplate')}
88
- </Button>
93
+ <PermissionGate feature="custom_forms" action="create">
94
+ <Button onClick={onNew} size="sm" className="mt-4">
95
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
96
+ {t('customForms.newTemplate')}
97
+ </Button>
98
+ </PermissionGate>
89
99
  </CardContent>
90
100
  </Card>
91
101
  )}
92
102
 
93
103
  {/* Template list */}
94
- {templates.length > 0 && (
104
+ {visibleTemplates.length > 0 && (
95
105
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
96
- {templates.map((template) => (
106
+ {visibleTemplates.map((template) => (
97
107
  <Card
98
108
  key={template.id}
99
109
  className="group cursor-pointer hover:shadow-md transition-shadow"
@@ -117,10 +127,11 @@ export function TemplateListView({ store, config, onEdit, onNew }: TemplateListV
117
127
  <Pencil className="h-3.5 w-3.5 text-muted-foreground" />
118
128
  </button>
119
129
  <button
120
- onClick={(e) => { e.stopPropagation(); handleDelete(template) }}
121
- className="p-1.5 rounded-md hover:bg-destructive/10"
130
+ onClick={(e) => { e.stopPropagation(); handleArchive(template) }}
131
+ className="p-1.5 rounded-md hover:bg-muted"
132
+ title={t('customForms.archive')}
122
133
  >
123
- <Trash2 className="h-3.5 w-3.5 text-destructive" />
134
+ <Archive className="h-3.5 w-3.5 text-muted-foreground" />
124
135
  </button>
125
136
  </div>
126
137
  </div>