@fayz-ai/plugin-forms 0.1.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 (99) hide show
  1. package/dist/CustomFormsContext.d.ts +15 -0
  2. package/dist/CustomFormsContext.d.ts.map +1 -0
  3. package/dist/FormBuilder-4VKYVZAC.cjs +619 -0
  4. package/dist/FormBuilder-4VKYVZAC.cjs.map +1 -0
  5. package/dist/FormBuilder-KDANEL6Z.js +613 -0
  6. package/dist/FormBuilder-KDANEL6Z.js.map +1 -0
  7. package/dist/chunk-32I43T37.js +198 -0
  8. package/dist/chunk-32I43T37.js.map +1 -0
  9. package/dist/chunk-XLUULIVL.cjs +200 -0
  10. package/dist/chunk-XLUULIVL.cjs.map +1 -0
  11. package/dist/components/AddDocumentDropdown.d.ts +9 -0
  12. package/dist/components/AddDocumentDropdown.d.ts.map +1 -0
  13. package/dist/components/BuilderCanvas.d.ts +12 -0
  14. package/dist/components/BuilderCanvas.d.ts.map +1 -0
  15. package/dist/components/BuilderField.d.ts +11 -0
  16. package/dist/components/BuilderField.d.ts.map +1 -0
  17. package/dist/components/DocumentFormDialog.d.ts +17 -0
  18. package/dist/components/DocumentFormDialog.d.ts.map +1 -0
  19. package/dist/components/DocumentList.d.ts +14 -0
  20. package/dist/components/DocumentList.d.ts.map +1 -0
  21. package/dist/components/FieldConfigDialog.d.ts +12 -0
  22. package/dist/components/FieldConfigDialog.d.ts.map +1 -0
  23. package/dist/components/FieldPalette.d.ts +10 -0
  24. package/dist/components/FieldPalette.d.ts.map +1 -0
  25. package/dist/components/FormBuilder.d.ts +14 -0
  26. package/dist/components/FormBuilder.d.ts.map +1 -0
  27. package/dist/components/FormRenderer.d.ts +11 -0
  28. package/dist/components/FormRenderer.d.ts.map +1 -0
  29. package/dist/components/FormViewer.d.ts +12 -0
  30. package/dist/components/FormViewer.d.ts.map +1 -0
  31. package/dist/components/PersonDocumentsWidget.d.ts +17 -0
  32. package/dist/components/PersonDocumentsWidget.d.ts.map +1 -0
  33. package/dist/components/TemplateSelector.d.ts +11 -0
  34. package/dist/components/TemplateSelector.d.ts.map +1 -0
  35. package/dist/config.d.ts +23 -0
  36. package/dist/config.d.ts.map +1 -0
  37. package/dist/data/index.d.ts +4 -0
  38. package/dist/data/index.d.ts.map +1 -0
  39. package/dist/data/mock.d.ts +3 -0
  40. package/dist/data/mock.d.ts.map +1 -0
  41. package/dist/data/supabase.d.ts +3 -0
  42. package/dist/data/supabase.d.ts.map +1 -0
  43. package/dist/data/types.d.ts +17 -0
  44. package/dist/data/types.d.ts.map +1 -0
  45. package/dist/document-types.d.ts +25 -0
  46. package/dist/document-types.d.ts.map +1 -0
  47. package/dist/index.cjs +1612 -0
  48. package/dist/index.cjs.map +1 -0
  49. package/dist/index.d.ts +6 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +1605 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/lib/tenant.d.ts +3 -0
  54. package/dist/lib/tenant.d.ts.map +1 -0
  55. package/dist/locales/en.d.ts +2 -0
  56. package/dist/locales/en.d.ts.map +1 -0
  57. package/dist/locales/index.d.ts +2 -0
  58. package/dist/locales/index.d.ts.map +1 -0
  59. package/dist/locales/pt-BR.d.ts +2 -0
  60. package/dist/locales/pt-BR.d.ts.map +1 -0
  61. package/dist/store.d.ts +29 -0
  62. package/dist/store.d.ts.map +1 -0
  63. package/dist/types.d.ts +150 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/views/CustomFormsSettingsTab.d.ts +14 -0
  66. package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -0
  67. package/dist/views/TemplateListView.d.ts +13 -0
  68. package/dist/views/TemplateListView.d.ts.map +1 -0
  69. package/package.json +57 -0
  70. package/src/CustomFormsContext.tsx +28 -0
  71. package/src/components/AddDocumentDropdown.tsx +92 -0
  72. package/src/components/BuilderCanvas.tsx +87 -0
  73. package/src/components/BuilderField.tsx +81 -0
  74. package/src/components/DocumentFormDialog.tsx +134 -0
  75. package/src/components/DocumentList.tsx +250 -0
  76. package/src/components/FieldConfigDialog.tsx +203 -0
  77. package/src/components/FieldPalette.tsx +81 -0
  78. package/src/components/FormBuilder.tsx +345 -0
  79. package/src/components/FormRenderer.tsx +256 -0
  80. package/src/components/FormViewer.tsx +84 -0
  81. package/src/components/PersonDocumentsWidget.tsx +220 -0
  82. package/src/components/TemplateSelector.tsx +91 -0
  83. package/src/config.ts +39 -0
  84. package/src/data/index.ts +3 -0
  85. package/src/data/mock.ts +193 -0
  86. package/src/data/supabase.ts +405 -0
  87. package/src/data/types.ts +33 -0
  88. package/src/document-types.ts +51 -0
  89. package/src/index.ts +195 -0
  90. package/src/lib/tenant.ts +5 -0
  91. package/src/locales/en.ts +75 -0
  92. package/src/locales/index.ts +7 -0
  93. package/src/locales/pt-BR.ts +75 -0
  94. package/src/migrations/001_frm_base.sql +138 -0
  95. package/src/migrations/002_document_archetype.sql +135 -0
  96. package/src/store.ts +167 -0
  97. package/src/types.ts +203 -0
  98. package/src/views/CustomFormsSettingsTab.tsx +115 -0
  99. package/src/views/TemplateListView.tsx +163 -0
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import type { CustomFormsStore } from '../store';
3
+ import type { CustomFormsConfig } from '../config';
4
+ import type { FormTemplate } from '../types';
5
+ interface TemplateListViewProps {
6
+ store: CustomFormsStore;
7
+ config: CustomFormsConfig;
8
+ onEdit: (template: FormTemplate) => void;
9
+ onNew: () => void;
10
+ }
11
+ export declare function TemplateListView({ store, config, onEdit, onNew }: TemplateListViewProps): React.JSX.Element;
12
+ export {};
13
+ //# sourceMappingURL=TemplateListView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TemplateListView.d.ts","sourceRoot":"","sources":["../../src/views/TemplateListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA2C,MAAM,OAAO,CAAA;AAQ/D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAChD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,UAAU,CAAA;AAE9D,UAAU,qBAAqB;IAC7B,KAAK,EAAE,gBAAgB,CAAA;IACvB,MAAM,EAAE,iBAAiB,CAAA;IACzB,MAAM,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAA;IACxC,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAUD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,qBAAqB,qBAuIvF"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@fayz-ai/plugin-forms",
3
+ "version": "0.1.1",
4
+ "description": "Fayz SDK — plugin-forms plugin",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "peerDependencies": {
22
+ "react": "^18.0.0 || ^19.0.0",
23
+ "react-dom": "^18.0.0 || ^19.0.0"
24
+ },
25
+ "dependencies": {
26
+ "lucide-react": "^0.400.0",
27
+ "zustand": "^4.5.0",
28
+ "@dnd-kit/core": "^6.3.1",
29
+ "@dnd-kit/sortable": "^10.0.0",
30
+ "@dnd-kit/modifiers": "^9.0.0",
31
+ "@dnd-kit/utilities": "^3.2.2",
32
+ "@fayz-ai/ui": "^0.1.6",
33
+ "@fayz-ai/core": "^0.1.6"
34
+ },
35
+ "devDependencies": {
36
+ "@types/react": "^18.3.0",
37
+ "react": "^18.3.0",
38
+ "tsup": "^8.2.0",
39
+ "typescript": "^5.5.0",
40
+ "@types/react-dom": "^18.3.0"
41
+ },
42
+ "license": "MIT",
43
+ "keywords": [
44
+ "fayz",
45
+ "fayz-plugin",
46
+ "fayz-sdk"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
53
+ "dev": "tsup --watch",
54
+ "typecheck": "tsc --noEmit",
55
+ "clean": "rm -rf dist"
56
+ }
57
+ }
@@ -0,0 +1,28 @@
1
+ import React, { createContext, useContext } from 'react'
2
+ import type { CustomFormsConfig } from './config'
3
+ import type { CustomFormsDataProvider } from './data/types'
4
+ import type { CustomFormsStore } from './store'
5
+
6
+ export interface CustomFormsContextValue {
7
+ config: CustomFormsConfig
8
+ provider: CustomFormsDataProvider
9
+ store: CustomFormsStore
10
+ }
11
+
12
+ const Ctx = createContext<CustomFormsContextValue | null>(null)
13
+
14
+ export function CustomFormsProvider({
15
+ value,
16
+ children,
17
+ }: {
18
+ value: CustomFormsContextValue
19
+ children: React.ReactNode
20
+ }) {
21
+ return React.createElement(Ctx.Provider, { value }, children)
22
+ }
23
+
24
+ export function useCustomFormsContext(): CustomFormsContextValue {
25
+ const ctx = useContext(Ctx)
26
+ if (!ctx) throw new Error('useCustomFormsContext must be used within CustomFormsProvider')
27
+ return ctx
28
+ }
@@ -0,0 +1,92 @@
1
+ import React, { useState, useCallback, useRef } from 'react'
2
+ import { Plus, ChevronDown } from 'lucide-react'
3
+ import { Button } from '@fayz-ai/ui'
4
+ import {
5
+ Dropdown,
6
+ DropdownTrigger,
7
+ DropdownContent,
8
+ DropdownItem,
9
+ DropdownLabel,
10
+ DropdownSeparator,
11
+ } from '@fayz-ai/ui'
12
+ import { useTranslation } from '@fayz-ai/core'
13
+ import { getAllDocumentTypes, type DocumentTypeOption } from '../document-types'
14
+
15
+ interface AddDocumentDropdownProps {
16
+ personId: string
17
+ onSelect: (option: DocumentTypeOption) => void
18
+ }
19
+
20
+ export function AddDocumentDropdown({ personId, onSelect }: AddDocumentDropdownProps) {
21
+ const t = useTranslation()
22
+ const [options, setOptions] = useState<DocumentTypeOption[]>([])
23
+ const [loaded, setLoaded] = useState(false)
24
+ const loadingRef = useRef(false)
25
+
26
+ const handleOpen = useCallback((open: boolean) => {
27
+ if (!open || loaded || loadingRef.current) return
28
+ loadingRef.current = true
29
+ getAllDocumentTypes(personId).then((opts) => {
30
+ setOptions(opts)
31
+ setLoaded(true)
32
+ loadingRef.current = false
33
+ })
34
+ }, [personId, loaded])
35
+
36
+ // Group options by group label
37
+ const groups: Array<{ label: string; items: DocumentTypeOption[] }> = []
38
+ for (const opt of options) {
39
+ let group = groups.find((g) => g.label === opt.group)
40
+ if (!group) {
41
+ group = { label: opt.group, items: [] }
42
+ groups.push(group)
43
+ }
44
+ group.items.push(opt)
45
+ }
46
+
47
+ return (
48
+ <Dropdown onOpenChange={handleOpen}>
49
+ <DropdownTrigger asChild>
50
+ <Button size="sm" className="h-8">
51
+ <Plus className="h-3.5 w-3.5 mr-1" />
52
+ {t('customForms.addDocument')}
53
+ <ChevronDown className="h-3 w-3 ml-1 opacity-50" />
54
+ </Button>
55
+ </DropdownTrigger>
56
+ <DropdownContent align="end" className="w-56">
57
+ {groups.map((group, gi) => (
58
+ <React.Fragment key={group.label}>
59
+ {gi > 0 && <DropdownSeparator />}
60
+ <DropdownLabel className="text-[10px] uppercase tracking-wider">
61
+ {group.label}
62
+ </DropdownLabel>
63
+ {group.items.map((opt) => {
64
+ const Icon = opt.icon
65
+ return (
66
+ <DropdownItem key={opt.id} onClick={() => onSelect(opt)} className="gap-2">
67
+ <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
68
+ <div className="flex-1 min-w-0">
69
+ <p className="text-sm truncate">{opt.label}</p>
70
+ {opt.description && (
71
+ <p className="text-[10px] text-muted-foreground truncate">{opt.description}</p>
72
+ )}
73
+ </div>
74
+ </DropdownItem>
75
+ )
76
+ })}
77
+ </React.Fragment>
78
+ ))}
79
+ {!loaded && (
80
+ <div className="flex items-center justify-center py-3">
81
+ <div className="h-4 w-4 border-2 border-primary border-t-transparent animate-spin rounded-full" />
82
+ </div>
83
+ )}
84
+ {loaded && options.length === 0 && (
85
+ <div className="px-2 py-3 text-center text-xs text-muted-foreground">
86
+ {t('customForms.noTemplates')}
87
+ </div>
88
+ )}
89
+ </DropdownContent>
90
+ </Dropdown>
91
+ )
92
+ }
@@ -0,0 +1,87 @@
1
+ import React from 'react'
2
+ import { useDroppable } from '@dnd-kit/core'
3
+ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
4
+ import { useTranslation } from '@fayz-ai/core'
5
+ import type { FormFieldDef, FormFieldType } from '../types'
6
+ import { BuilderField } from './BuilderField'
7
+ import { FIELD_TYPES, DEFAULT_COL_SPAN } from './FieldPalette'
8
+
9
+ interface BuilderCanvasProps {
10
+ fields: FormFieldDef[]
11
+ selectedFieldId: string | null
12
+ onSelectField: (field: FormFieldDef) => void
13
+ onRemoveField: (fieldId: string) => void
14
+ draggingFieldType: FormFieldType | null
15
+ }
16
+
17
+ function DropPreview({ fieldType }: { fieldType: FormFieldType }) {
18
+ const t = useTranslation()
19
+ const def = FIELD_TYPES.find((ft) => ft.type === fieldType)
20
+ if (!def) return null
21
+ const Icon = def.icon
22
+
23
+ return (
24
+ <div
25
+ className="flex items-center gap-2 rounded-lg border-2 border-dashed border-primary/40 bg-primary/5 p-3"
26
+ style={{ gridColumn: `span ${DEFAULT_COL_SPAN[fieldType] ?? 12}` }}
27
+ >
28
+ <Icon className="h-4 w-4 text-primary/50" />
29
+ <span className="text-sm text-primary/50 font-medium">
30
+ {t(`customForms.fieldType.${fieldType}`)}
31
+ </span>
32
+ </div>
33
+ )
34
+ }
35
+
36
+ export function BuilderCanvas({
37
+ fields,
38
+ selectedFieldId,
39
+ onSelectField,
40
+ onRemoveField,
41
+ draggingFieldType,
42
+ }: BuilderCanvasProps) {
43
+ const t = useTranslation()
44
+ const { setNodeRef, isOver } = useDroppable({ id: 'builder-canvas' })
45
+
46
+ const sortedFields = [...fields].sort((a, b) => a.row - b.row || a.col - b.col)
47
+ const showPreview = isOver && draggingFieldType !== null
48
+
49
+ return (
50
+ <div
51
+ ref={setNodeRef}
52
+ className={`flex-1 min-h-[400px] rounded-xl border-2 border-dashed p-4 transition-colors ${
53
+ isOver ? 'border-primary/50 bg-primary/5' : 'border-border bg-muted/20'
54
+ }`}
55
+ style={{
56
+ backgroundImage:
57
+ 'repeating-linear-gradient(90deg, transparent, transparent calc(8.333% - 1px), hsl(var(--border) / 0.3) calc(8.333% - 1px), hsl(var(--border) / 0.3) 8.333%)',
58
+ backgroundSize: '100% 100%',
59
+ }}
60
+ >
61
+ {fields.length === 0 && !showPreview && (
62
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
63
+ {t('customForms.builder.emptyCanvas')}
64
+ </div>
65
+ )}
66
+
67
+ <SortableContext items={sortedFields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
68
+ <div
69
+ className="grid gap-3"
70
+ style={{ gridTemplateColumns: 'repeat(12, 1fr)' }}
71
+ >
72
+ {sortedFields.map((field) => (
73
+ <BuilderField
74
+ key={field.id}
75
+ field={field}
76
+ isSelected={field.id === selectedFieldId}
77
+ onSelect={onSelectField}
78
+ onRemove={onRemoveField}
79
+ />
80
+ ))}
81
+
82
+ {showPreview && <DropPreview fieldType={draggingFieldType!} />}
83
+ </div>
84
+ </SortableContext>
85
+ </div>
86
+ )
87
+ }
@@ -0,0 +1,81 @@
1
+ import React from 'react'
2
+ import { useSortable } from '@dnd-kit/sortable'
3
+ import { CSS } from '@dnd-kit/utilities'
4
+ import { GripVertical, X } from 'lucide-react'
5
+ import { useTranslation } from '@fayz-ai/core'
6
+ import type { FormFieldDef } from '../types'
7
+ import { FIELD_TYPES } from './FieldPalette'
8
+
9
+ interface BuilderFieldProps {
10
+ field: FormFieldDef
11
+ onSelect: (field: FormFieldDef) => void
12
+ onRemove: (fieldId: string) => void
13
+ isSelected: boolean
14
+ }
15
+
16
+ export function BuilderField({ field, onSelect, onRemove, isSelected }: BuilderFieldProps) {
17
+ const t = useTranslation()
18
+ const {
19
+ attributes,
20
+ listeners,
21
+ setNodeRef,
22
+ transform,
23
+ transition,
24
+ isDragging,
25
+ } = useSortable({ id: field.id })
26
+
27
+ const style: React.CSSProperties = {
28
+ transform: CSS.Transform.toString(transform),
29
+ transition,
30
+ gridColumn: `span ${field.colSpan}`,
31
+ opacity: isDragging ? 0.5 : 1,
32
+ }
33
+
34
+ const fieldTypeDef = FIELD_TYPES.find((ft) => ft.type === field.type)
35
+ const Icon = fieldTypeDef?.icon
36
+
37
+ return (
38
+ <div
39
+ ref={setNodeRef}
40
+ style={style}
41
+ className={`group relative flex items-start gap-2 rounded-lg border-2 bg-card p-3 transition-colors cursor-pointer ${
42
+ isSelected
43
+ ? 'border-primary ring-1 ring-primary/20'
44
+ : 'border-border hover:border-primary/30'
45
+ }`}
46
+ onClick={() => onSelect(field)}
47
+ >
48
+ {/* Drag handle */}
49
+ <button
50
+ {...attributes}
51
+ {...listeners}
52
+ className="mt-0.5 cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground"
53
+ onClick={(e) => e.stopPropagation()}
54
+ >
55
+ <GripVertical className="h-4 w-4" />
56
+ </button>
57
+
58
+ {/* Content */}
59
+ <div className="flex-1 min-w-0">
60
+ <div className="flex items-center gap-1.5">
61
+ {Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
62
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
63
+ {t(`customForms.fieldType.${field.type}`)}
64
+ </span>
65
+ </div>
66
+ <p className="text-sm font-medium mt-0.5 truncate">{field.label}</p>
67
+ {field.required && (
68
+ <span className="text-[10px] text-destructive">*</span>
69
+ )}
70
+ </div>
71
+
72
+ {/* Remove button */}
73
+ <button
74
+ onClick={(e) => { e.stopPropagation(); onRemove(field.id) }}
75
+ className="absolute top-1.5 right-1.5 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
76
+ >
77
+ <X className="h-3 w-3 text-destructive" />
78
+ </button>
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,134 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { ArrowLeft, Save, Loader2 } from 'lucide-react'
3
+ import { Button, useSaveBar } from '@fayz-ai/ui'
4
+ import { useTranslation } from '@fayz-ai/core'
5
+ import type { CustomFormsDataProvider } from '../data/types'
6
+ import type { CustomFormsStore } from '../store'
7
+ import type { FormTemplate, FormDocument } from '../types'
8
+ import { FormRenderer } from './FormRenderer'
9
+
10
+ function generateTitle(templateName: string, personName?: string): string {
11
+ const date = new Date().toLocaleDateString()
12
+ return personName
13
+ ? `${templateName} — ${personName} — ${date}`
14
+ : `${templateName} — ${date}`
15
+ }
16
+
17
+ interface DocumentFormDialogProps {
18
+ template: FormTemplate | null
19
+ existingDocument?: FormDocument
20
+ personId: string
21
+ personName?: string
22
+ provider: CustomFormsDataProvider
23
+ store: CustomFormsStore
24
+ onSaved: () => void
25
+ onBack: () => void
26
+ }
27
+
28
+ export function DocumentFormDialog({
29
+ template: initialTemplate,
30
+ existingDocument,
31
+ personId,
32
+ personName,
33
+ provider,
34
+ store,
35
+ onSaved,
36
+ onBack,
37
+ }: DocumentFormDialogProps) {
38
+ const t = useTranslation()
39
+ const [template, setTemplate] = useState<FormTemplate | null>(initialTemplate)
40
+ const [data, setData] = useState<Record<string, unknown>>(existingDocument?.data ?? {})
41
+ const [saving, setSaving] = useState(false)
42
+
43
+ useEffect(() => {
44
+ if (existingDocument && !template) {
45
+ provider.getTemplateById(existingDocument.templateId).then(setTemplate)
46
+ }
47
+ }, [existingDocument, template, provider])
48
+
49
+ const handleSave = useCallback(
50
+ async (status: 'draft' | 'completed') => {
51
+ if (!template) return
52
+ setSaving(true)
53
+ try {
54
+ const autoTitle = generateTitle(template.name, personName)
55
+ if (existingDocument) {
56
+ await store.getState().updateDocument(existingDocument.id, { data, status })
57
+ } else {
58
+ await store.getState().createDocument({
59
+ templateId: template.id,
60
+ personId,
61
+ title: autoTitle,
62
+ data,
63
+ status,
64
+ })
65
+ }
66
+ onSaved()
67
+ } finally {
68
+ setSaving(false)
69
+ }
70
+ },
71
+ [template, existingDocument, data, personId, personName, store, onSaved],
72
+ )
73
+
74
+ // Dirty detection — snapshot editable field values once template is ready
75
+ const snapshot = React.useRef<string | null>(null)
76
+ useEffect(() => {
77
+ if (template && snapshot.current === null) snapshot.current = JSON.stringify(data)
78
+ }, [template])
79
+ const dirty = snapshot.current !== null && JSON.stringify(data) !== snapshot.current
80
+
81
+ useSaveBar({
82
+ dirty,
83
+ saving,
84
+ onSave: () => { void handleSave('completed') },
85
+ onDiscard: () => onBack(),
86
+ saveLabel: t('customForms.saveAndComplete'),
87
+ })
88
+
89
+ if (!template) {
90
+ return (
91
+ <div className="flex items-center justify-center py-12">
92
+ <div className="h-5 w-5 border-2 border-primary border-t-transparent animate-spin rounded-full" />
93
+ </div>
94
+ )
95
+ }
96
+
97
+ return (
98
+ <div className="space-y-4">
99
+ {/* Header */}
100
+ <div className="flex items-center gap-3">
101
+ <button
102
+ onClick={onBack}
103
+ className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
104
+ >
105
+ <ArrowLeft className="h-3.5 w-3.5" />
106
+ </button>
107
+ <div className="flex-1 min-w-0">
108
+ <h3 className="font-semibold text-sm">{template.name}</h3>
109
+ <p className="text-xs text-muted-foreground">
110
+ {personName && <span>{personName} &middot; </span>}
111
+ {new Date().toLocaleDateString()}
112
+ </p>
113
+ </div>
114
+ </div>
115
+
116
+ {/* Form fields */}
117
+ <div className="rounded-xl border p-4">
118
+ <FormRenderer
119
+ schema={template.schema}
120
+ data={data}
121
+ onChange={setData}
122
+ />
123
+ </div>
124
+
125
+ {/* Secondary action — primary "Save & Complete" lives in the floating SaveBar */}
126
+ <div className="flex justify-end gap-2">
127
+ <Button variant="outline" size="sm" onClick={() => handleSave('draft')} disabled={saving}>
128
+ {saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
129
+ {t('customForms.saveAsDraft')}
130
+ </Button>
131
+ </div>
132
+ </div>
133
+ )
134
+ }