@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,220 @@
1
+ import React, { useState, useCallback, useRef } from 'react'
2
+ import { ArrowLeft, Image as ImageIcon, Paperclip } from 'lucide-react'
3
+ import { Badge } from '@fayz-ai/ui'
4
+ import { useTranslation } from '@fayz-ai/core'
5
+ import type { CustomFormsConfig } from '../config'
6
+ import type { CustomFormsDataProvider } from '../data/types'
7
+ import type { CustomFormsStore } from '../store'
8
+ import type { FormDocument, FormTemplate } from '../types'
9
+ import type { DocumentTypeOption } from '../document-types'
10
+ import { DocumentList } from './DocumentList'
11
+ import { DocumentFormDialog } from './DocumentFormDialog'
12
+ import { FormViewer } from './FormViewer'
13
+ import { AddDocumentDropdown } from './AddDocumentDropdown'
14
+
15
+ interface PersonDocumentsWidgetProps {
16
+ item: { id: string; name?: string; [key: string]: any }
17
+ config: CustomFormsConfig
18
+ provider: CustomFormsDataProvider
19
+ store: CustomFormsStore
20
+ }
21
+
22
+ type WidgetView =
23
+ | { type: 'list' }
24
+ | { type: 'fill'; template: FormTemplate }
25
+ | { type: 'view'; document: FormDocument }
26
+ | { type: 'edit'; document: FormDocument }
27
+
28
+ export function PersonDocumentsWidget({ item, config, provider, store }: PersonDocumentsWidgetProps) {
29
+ const t = useTranslation()
30
+ const [view, setView] = useState<WidgetView>({ type: 'list' })
31
+ const fileInputRef = useRef<HTMLInputElement>(null)
32
+ const [pendingFileAccept, setPendingFileAccept] = useState<string>('*/*')
33
+
34
+ const handleDocTypeSelect = useCallback(async (option: DocumentTypeOption) => {
35
+ // Form template
36
+ if (option.id.startsWith('template:')) {
37
+ const templateId = option.id.replace('template:', '')
38
+ const template = await provider.getTemplateById(templateId)
39
+ if (template) {
40
+ setView({ type: 'fill', template })
41
+ }
42
+ return
43
+ }
44
+
45
+ // Image — open file picker for images
46
+ if (option.id === 'core:image') {
47
+ setPendingFileAccept('image/*')
48
+ setTimeout(() => fileInputRef.current?.click(), 0)
49
+ return
50
+ }
51
+
52
+ // Attachment — open file picker for any file
53
+ if (option.id === 'core:attachment') {
54
+ setPendingFileAccept('*/*')
55
+ setTimeout(() => fileInputRef.current?.click(), 0)
56
+ return
57
+ }
58
+ }, [provider])
59
+
60
+ const handleFiles = useCallback(async (files: File[]) => {
61
+ for (const file of files) {
62
+ const isImage = file.type.startsWith('image/')
63
+ await store.getState().createDocument({
64
+ kind: isImage ? 'image' : 'attachment',
65
+ personId: item.id,
66
+ title: file.name,
67
+ fileName: file.name,
68
+ fileSize: file.size,
69
+ mimeType: file.type,
70
+ status: 'completed',
71
+ })
72
+ }
73
+ }, [store, item.id])
74
+
75
+ const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
76
+ const files = Array.from(e.target.files ?? [])
77
+ if (files.length > 0) handleFiles(files)
78
+ e.target.value = ''
79
+ }, [handleFiles])
80
+
81
+ const handleViewDoc = useCallback((doc: FormDocument) => {
82
+ setView({ type: 'view', document: doc })
83
+ }, [])
84
+
85
+ const handleEditDoc = useCallback((doc: FormDocument) => {
86
+ setView({ type: 'edit', document: doc })
87
+ }, [])
88
+
89
+ const handleBack = useCallback(() => {
90
+ setView({ type: 'list' })
91
+ }, [])
92
+
93
+ const handleSaved = useCallback(() => {
94
+ setView({ type: 'list' })
95
+ }, [])
96
+
97
+ // Fill form
98
+ if (view.type === 'fill') {
99
+ return (
100
+ <DocumentFormDialog
101
+ template={view.template}
102
+ personId={item.id}
103
+ personName={item.name}
104
+ provider={provider}
105
+ store={store}
106
+ onSaved={handleSaved}
107
+ onBack={handleBack}
108
+ />
109
+ )
110
+ }
111
+
112
+ // View document — route by kind
113
+ if (view.type === 'view') {
114
+ const doc = view.document
115
+ const isForm = doc.kind === 'form' || (!doc.kind && doc.templateId)
116
+
117
+ if (!isForm) {
118
+ // File document (image/attachment) — simple preview
119
+ return (
120
+ <FileDocumentViewer document={doc} onBack={handleBack} />
121
+ )
122
+ }
123
+
124
+ return (
125
+ <FormViewer
126
+ document={doc}
127
+ provider={provider}
128
+ onBack={handleBack}
129
+ onEdit={handleEditDoc}
130
+ />
131
+ )
132
+ }
133
+
134
+ // Edit document — only for form docs
135
+ if (view.type === 'edit') {
136
+ return (
137
+ <DocumentFormDialog
138
+ template={null}
139
+ existingDocument={view.document}
140
+ personId={item.id}
141
+ personName={item.name}
142
+ provider={provider}
143
+ store={store}
144
+ onSaved={handleSaved}
145
+ onBack={handleBack}
146
+ />
147
+ )
148
+ }
149
+
150
+ // Default: list with dropdown + drag zone
151
+ return (
152
+ <div className="space-y-3">
153
+ <div className="flex items-center justify-between">
154
+ <h3 className="text-sm font-semibold">{t('customForms.documents')}</h3>
155
+ <AddDocumentDropdown personId={item.id} onSelect={handleDocTypeSelect} />
156
+ </div>
157
+ <DocumentList
158
+ personId={item.id}
159
+ provider={provider}
160
+ store={store}
161
+ onView={handleViewDoc}
162
+ onFileDrop={handleFiles}
163
+ />
164
+ {/* Hidden file input for image/attachment upload */}
165
+ <input
166
+ ref={fileInputRef}
167
+ type="file"
168
+ className="hidden"
169
+ accept={pendingFileAccept}
170
+ multiple
171
+ onChange={handleFileInputChange}
172
+ />
173
+ </div>
174
+ )
175
+ }
176
+
177
+ function FileDocumentViewer({ document: doc, onBack }: { document: FormDocument; onBack: () => void }) {
178
+ const isImage = doc.kind === 'image' || doc.mimeType?.startsWith('image/')
179
+ const Icon = isImage ? ImageIcon : Paperclip
180
+
181
+ return (
182
+ <div className="space-y-4">
183
+ <div className="flex items-center gap-3">
184
+ <button
185
+ onClick={onBack}
186
+ className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
187
+ >
188
+ <ArrowLeft className="h-3.5 w-3.5" />
189
+ </button>
190
+ <div className="flex-1 min-w-0">
191
+ <h3 className="font-semibold text-sm truncate">{doc.title ?? doc.fileName ?? 'Document'}</h3>
192
+ <p className="text-xs text-muted-foreground">
193
+ {new Date(doc.createdAt).toLocaleDateString()}
194
+ </p>
195
+ </div>
196
+ <Badge variant="secondary" className="text-[10px] bg-success/15 text-success">
197
+ {doc.status === 'completed' ? 'Finalizado' : doc.status}
198
+ </Badge>
199
+ </div>
200
+
201
+ <div className="rounded-xl border p-6 flex flex-col items-center gap-4">
202
+ {isImage && doc.fileUrl ? (
203
+ <img src={doc.fileUrl} alt={doc.fileName ?? ''} className="max-w-full max-h-[500px] rounded-lg object-contain" />
204
+ ) : (
205
+ <div className="flex flex-col items-center gap-3 py-8">
206
+ <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
207
+ <Icon className="h-7 w-7 text-muted-foreground" />
208
+ </div>
209
+ <p className="text-sm font-medium">{doc.fileName ?? doc.title ?? 'Arquivo'}</p>
210
+ {doc.fileSize && (
211
+ <p className="text-xs text-muted-foreground">
212
+ {(doc.fileSize / 1024).toFixed(1)} KB
213
+ </p>
214
+ )}
215
+ </div>
216
+ )}
217
+ </div>
218
+ </div>
219
+ )
220
+ }
@@ -0,0 +1,91 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { FileText, Search } from 'lucide-react'
3
+ import { Input } from '@fayz-ai/ui'
4
+ import { Badge } from '@fayz-ai/ui'
5
+ import { useTranslation } from '@fayz-ai/core'
6
+ import type { CustomFormsDataProvider } from '../data/types'
7
+ import type { FormTemplate } from '../types'
8
+
9
+ interface TemplateSelectorProps {
10
+ provider: CustomFormsDataProvider
11
+ onSelect: (template: FormTemplate) => void
12
+ onClose: () => void
13
+ }
14
+
15
+ export function TemplateSelector({ provider, onSelect, onClose }: TemplateSelectorProps) {
16
+ const t = useTranslation()
17
+ const [templates, setTemplates] = useState<FormTemplate[]>([])
18
+ const [search, setSearch] = useState('')
19
+ const [loading, setLoading] = useState(true)
20
+
21
+ useEffect(() => {
22
+ setLoading(true)
23
+ provider
24
+ .getTemplates({ search: search || undefined })
25
+ .then((result) => setTemplates(result.data))
26
+ .finally(() => setLoading(false))
27
+ }, [provider, search])
28
+
29
+ return (
30
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
31
+ <div
32
+ className="bg-card rounded-xl border shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col"
33
+ onClick={(e) => e.stopPropagation()}
34
+ >
35
+ {/* Header */}
36
+ <div className="p-4 border-b">
37
+ <h3 className="font-semibold text-sm mb-3">{t('customForms.selectTemplate')}</h3>
38
+ <div className="relative">
39
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
40
+ <Input
41
+ value={search}
42
+ onChange={(e) => setSearch(e.target.value)}
43
+ placeholder={t('common.search')}
44
+ className="h-9 pl-8 text-sm"
45
+ autoFocus
46
+ />
47
+ </div>
48
+ </div>
49
+
50
+ {/* List */}
51
+ <div className="flex-1 overflow-y-auto p-2">
52
+ {loading && (
53
+ <div className="flex items-center justify-center py-8">
54
+ <div className="h-5 w-5 border-2 border-primary border-t-transparent animate-spin rounded-full" />
55
+ </div>
56
+ )}
57
+
58
+ {!loading && templates.length === 0 && (
59
+ <div className="flex flex-col items-center justify-center py-8 text-center">
60
+ <FileText className="h-8 w-8 text-muted-foreground/50 mb-2" />
61
+ <p className="text-sm text-muted-foreground">{t('customForms.noTemplates')}</p>
62
+ </div>
63
+ )}
64
+
65
+ {templates.map((tpl) => (
66
+ <button
67
+ key={tpl.id}
68
+ onClick={() => onSelect(tpl)}
69
+ className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg hover:bg-muted transition-colors text-left"
70
+ >
71
+ <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0">
72
+ <FileText className="h-4 w-4 text-primary" />
73
+ </div>
74
+ <div className="flex-1 min-w-0">
75
+ <p className="text-sm font-medium truncate">{tpl.name}</p>
76
+ <div className="flex items-center gap-2 mt-0.5">
77
+ <Badge variant="secondary" className="text-[10px]">
78
+ {t(`customForms.category.${tpl.category}`)}
79
+ </Badge>
80
+ <span className="text-[10px] text-muted-foreground">
81
+ {tpl.schema.fields.length} {t('customForms.builder.fields').toLowerCase()}
82
+ </span>
83
+ </div>
84
+ </div>
85
+ </button>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ )
91
+ }
package/src/config.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { PluginScope, VerticalId } from '@fayz-ai/core'
2
+ import type { CustomFormsDataProvider } from './data/types'
3
+
4
+ export interface CustomFormsPluginOptions {
5
+ scope?: PluginScope
6
+ verticalId?: VerticalId
7
+ dataProvider?: CustomFormsDataProvider
8
+ navSection?: 'main' | 'secondary' | 'settings'
9
+ navPosition?: number
10
+ labels?: Partial<CustomFormsLabels>
11
+ }
12
+
13
+ export interface CustomFormsLabels {
14
+ pageTitle: string
15
+ settingsLabel: string
16
+ templates: string
17
+ documents: string
18
+ newTemplate: string
19
+ addDocument: string
20
+ }
21
+
22
+ export interface CustomFormsConfig {
23
+ labels: CustomFormsLabels
24
+ }
25
+
26
+ const DEFAULT_LABELS: CustomFormsLabels = {
27
+ pageTitle: 'Custom Forms',
28
+ settingsLabel: 'Forms & Documents',
29
+ templates: 'Templates',
30
+ documents: 'Documents',
31
+ newTemplate: 'New Template',
32
+ addDocument: 'Add Document',
33
+ }
34
+
35
+ export function resolveConfig(options?: CustomFormsPluginOptions): CustomFormsConfig {
36
+ return {
37
+ labels: { ...DEFAULT_LABELS, ...options?.labels },
38
+ }
39
+ }
@@ -0,0 +1,3 @@
1
+ export type { CustomFormsDataProvider } from './types'
2
+ export { createMockFormsProvider } from './mock'
3
+ export { createSupabaseFormsProvider } from './supabase'
@@ -0,0 +1,193 @@
1
+ import type { CustomFormsDataProvider } from './types'
2
+ import type {
3
+ FormTemplate,
4
+ FormDocument,
5
+ DocumentFile,
6
+ TemplateQuery,
7
+ DocumentQuery,
8
+ PaginatedResult,
9
+ CreateTemplateInput,
10
+ UpdateTemplateInput,
11
+ CreateDocumentInput,
12
+ UpdateDocumentInput,
13
+ } from '../types'
14
+
15
+ function uuid() {
16
+ return crypto.randomUUID()
17
+ }
18
+
19
+ function now() {
20
+ return new Date().toISOString()
21
+ }
22
+
23
+ export function createMockFormsProvider(): CustomFormsDataProvider {
24
+ const templates: FormTemplate[] = []
25
+ const documents: FormDocument[] = []
26
+ const files: DocumentFile[] = []
27
+
28
+ return {
29
+ // ── Templates ──────────────────────────────────────────
30
+ async getTemplates(query: TemplateQuery): Promise<PaginatedResult<FormTemplate>> {
31
+ let filtered = templates.filter((t) => !t.isDeleted && t.isCurrent)
32
+ if (query.category) {
33
+ filtered = filtered.filter((t) => t.category === query.category)
34
+ }
35
+ if (query.search) {
36
+ const s = query.search.toLowerCase()
37
+ filtered = filtered.filter((t) => t.name.toLowerCase().includes(s))
38
+ }
39
+ const page = query.page ?? 1
40
+ const pageSize = query.pageSize ?? 25
41
+ const start = (page - 1) * pageSize
42
+ return { data: filtered.slice(start, start + pageSize), total: filtered.length }
43
+ },
44
+
45
+ async getTemplateById(id: string): Promise<FormTemplate | null> {
46
+ return templates.find((t) => t.id === id && !t.isDeleted) ?? null
47
+ },
48
+
49
+ async createTemplate(input: CreateTemplateInput): Promise<FormTemplate> {
50
+ const template: FormTemplate = {
51
+ id: uuid(),
52
+ tenantId: 'mock-tenant',
53
+ name: input.name,
54
+ description: input.description,
55
+ category: input.category,
56
+ version: 1,
57
+ isCurrent: true,
58
+ schema: input.schema,
59
+ specialty: input.specialty,
60
+ tags: input.tags ?? [],
61
+ metadata: input.metadata ?? {},
62
+ isActive: true,
63
+ isDeleted: false,
64
+ createdAt: now(),
65
+ updatedAt: now(),
66
+ }
67
+ templates.push(template)
68
+ return template
69
+ },
70
+
71
+ async updateTemplate(id: string, input: UpdateTemplateInput): Promise<FormTemplate> {
72
+ const idx = templates.findIndex((t) => t.id === id)
73
+ if (idx === -1) throw new Error('Template not found')
74
+ const existing = templates[idx]
75
+ const updated: FormTemplate = {
76
+ ...existing,
77
+ ...input,
78
+ tags: input.tags ?? existing.tags,
79
+ metadata: input.metadata ?? existing.metadata,
80
+ schema: input.schema ?? existing.schema,
81
+ updatedAt: now(),
82
+ }
83
+ templates[idx] = updated
84
+ return updated
85
+ },
86
+
87
+ async deleteTemplate(id: string): Promise<void> {
88
+ const idx = templates.findIndex((t) => t.id === id)
89
+ if (idx !== -1) templates[idx] = { ...templates[idx], isDeleted: true }
90
+ },
91
+
92
+ // ── Documents ──────────────────────────────────────────
93
+ async getDocuments(query: DocumentQuery): Promise<PaginatedResult<FormDocument>> {
94
+ let filtered = documents.filter((d) => !d.isDeleted)
95
+ if (query.personId) {
96
+ filtered = filtered.filter((d) => d.personId === query.personId)
97
+ }
98
+ if (query.templateId) {
99
+ filtered = filtered.filter((d) => d.templateId === query.templateId)
100
+ }
101
+ if (query.status) {
102
+ const statuses = Array.isArray(query.status) ? query.status : [query.status]
103
+ filtered = filtered.filter((d) => statuses.includes(d.status))
104
+ }
105
+ if (query.search) {
106
+ const s = query.search.toLowerCase()
107
+ filtered = filtered.filter(
108
+ (d) =>
109
+ d.title?.toLowerCase().includes(s) ||
110
+ d.templateName?.toLowerCase().includes(s),
111
+ )
112
+ }
113
+ filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
114
+ const page = query.page ?? 1
115
+ const pageSize = query.pageSize ?? 25
116
+ const start = (page - 1) * pageSize
117
+ return { data: filtered.slice(start, start + pageSize), total: filtered.length }
118
+ },
119
+
120
+ async getDocumentById(id: string): Promise<FormDocument | null> {
121
+ return documents.find((d) => d.id === id && !d.isDeleted) ?? null
122
+ },
123
+
124
+ async createDocument(input: CreateDocumentInput): Promise<FormDocument> {
125
+ const template = templates.find((t) => t.id === input.templateId)
126
+ const doc: FormDocument = {
127
+ id: uuid(),
128
+ tenantId: 'mock-tenant',
129
+ templateId: input.templateId ?? '',
130
+ templateName: template?.name,
131
+ templateCategory: template?.category,
132
+ personId: input.personId,
133
+ title: input.title,
134
+ data: input.data ?? {},
135
+ kind: input.kind ?? (input.templateId ? 'form' : 'attachment'),
136
+ status: input.status ?? 'draft',
137
+ metadata: {},
138
+ isDeleted: false,
139
+ createdAt: now(),
140
+ updatedAt: now(),
141
+ }
142
+ documents.push(doc)
143
+ return doc
144
+ },
145
+
146
+ async updateDocument(id: string, input: UpdateDocumentInput): Promise<FormDocument> {
147
+ const idx = documents.findIndex((d) => d.id === id)
148
+ if (idx === -1) throw new Error('Document not found')
149
+ const existing = documents[idx]
150
+ const updated: FormDocument = {
151
+ ...existing,
152
+ ...input,
153
+ data: input.data ?? existing.data,
154
+ updatedAt: now(),
155
+ }
156
+ documents[idx] = updated
157
+ return updated
158
+ },
159
+
160
+ async deleteDocument(id: string): Promise<void> {
161
+ const idx = documents.findIndex((d) => d.id === id)
162
+ if (idx !== -1) documents[idx] = { ...documents[idx], isDeleted: true }
163
+ },
164
+
165
+ // ── Files ──────────────────────────────────────────────
166
+ async getDocumentFiles(documentId: string): Promise<DocumentFile[]> {
167
+ return files.filter((f) => f.documentId === documentId)
168
+ },
169
+
170
+ async uploadFile(documentId: string, fieldKey: string, file: File): Promise<DocumentFile> {
171
+ const docFile: DocumentFile = {
172
+ id: uuid(),
173
+ tenantId: 'mock-tenant',
174
+ documentId,
175
+ fieldKey,
176
+ fileUrl: URL.createObjectURL(file),
177
+ fileName: file.name,
178
+ fileSize: file.size,
179
+ mimeType: file.type,
180
+ sortOrder: files.filter((f) => f.documentId === documentId && f.fieldKey === fieldKey).length,
181
+ metadata: {},
182
+ createdAt: now(),
183
+ }
184
+ files.push(docFile)
185
+ return docFile
186
+ },
187
+
188
+ async deleteFile(fileId: string): Promise<void> {
189
+ const idx = files.findIndex((f) => f.id === fileId)
190
+ if (idx !== -1) files.splice(idx, 1)
191
+ },
192
+ }
193
+ }