@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.
- package/dist/{FormBuilder-KDANEL6Z.js → FormBuilder-7SWZQKFN.js} +130 -23
- package/dist/FormBuilder-7SWZQKFN.js.map +1 -0
- package/dist/agent-tools.d.ts +4 -0
- package/dist/agent-tools.d.ts.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.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 +13 -11
- 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 +0 -619
- 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
- package/dist/index.cjs +0 -1629
- package/dist/index.cjs.map +0 -1
package/src/locales/pt-BR.ts
CHANGED
|
@@ -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;
|
package/src/migrations/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
+
window.location.hash = `#${ROUTE_BASE}/templates/new`
|
|
19
32
|
}, [])
|
|
20
33
|
|
|
21
34
|
const handleEdit = useCallback((template: FormTemplate) => {
|
|
22
|
-
|
|
35
|
+
window.location.hash = `#${ROUTE_BASE}/templates/${template.id}`
|
|
23
36
|
}, [])
|
|
24
37
|
|
|
25
38
|
const handleBackFromBuilder = useCallback(() => {
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
<
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
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 &&
|
|
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
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
{
|
|
104
|
+
{visibleTemplates.length > 0 && (
|
|
95
105
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
96
|
-
{
|
|
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();
|
|
121
|
-
className="p-1.5 rounded-md hover:bg-
|
|
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
|
-
<
|
|
134
|
+
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
|
|
124
135
|
</button>
|
|
125
136
|
</div>
|
|
126
137
|
</div>
|