@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.
- package/dist/CustomFormsContext.d.ts +15 -0
- package/dist/CustomFormsContext.d.ts.map +1 -0
- package/dist/FormBuilder-4VKYVZAC.cjs +619 -0
- package/dist/FormBuilder-4VKYVZAC.cjs.map +1 -0
- package/dist/FormBuilder-KDANEL6Z.js +613 -0
- package/dist/FormBuilder-KDANEL6Z.js.map +1 -0
- package/dist/chunk-32I43T37.js +198 -0
- package/dist/chunk-32I43T37.js.map +1 -0
- package/dist/chunk-XLUULIVL.cjs +200 -0
- package/dist/chunk-XLUULIVL.cjs.map +1 -0
- package/dist/components/AddDocumentDropdown.d.ts +9 -0
- package/dist/components/AddDocumentDropdown.d.ts.map +1 -0
- package/dist/components/BuilderCanvas.d.ts +12 -0
- package/dist/components/BuilderCanvas.d.ts.map +1 -0
- package/dist/components/BuilderField.d.ts +11 -0
- package/dist/components/BuilderField.d.ts.map +1 -0
- package/dist/components/DocumentFormDialog.d.ts +17 -0
- package/dist/components/DocumentFormDialog.d.ts.map +1 -0
- package/dist/components/DocumentList.d.ts +14 -0
- package/dist/components/DocumentList.d.ts.map +1 -0
- package/dist/components/FieldConfigDialog.d.ts +12 -0
- package/dist/components/FieldConfigDialog.d.ts.map +1 -0
- package/dist/components/FieldPalette.d.ts +10 -0
- package/dist/components/FieldPalette.d.ts.map +1 -0
- package/dist/components/FormBuilder.d.ts +14 -0
- package/dist/components/FormBuilder.d.ts.map +1 -0
- package/dist/components/FormRenderer.d.ts +11 -0
- package/dist/components/FormRenderer.d.ts.map +1 -0
- package/dist/components/FormViewer.d.ts +12 -0
- package/dist/components/FormViewer.d.ts.map +1 -0
- package/dist/components/PersonDocumentsWidget.d.ts +17 -0
- package/dist/components/PersonDocumentsWidget.d.ts.map +1 -0
- package/dist/components/TemplateSelector.d.ts +11 -0
- package/dist/components/TemplateSelector.d.ts.map +1 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +17 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/document-types.d.ts +25 -0
- package/dist/document-types.d.ts.map +1 -0
- package/dist/index.cjs +1612 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1605 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/store.d.ts +29 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CustomFormsSettingsTab.d.ts +14 -0
- package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -0
- package/dist/views/TemplateListView.d.ts +13 -0
- package/dist/views/TemplateListView.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/CustomFormsContext.tsx +28 -0
- package/src/components/AddDocumentDropdown.tsx +92 -0
- package/src/components/BuilderCanvas.tsx +87 -0
- package/src/components/BuilderField.tsx +81 -0
- package/src/components/DocumentFormDialog.tsx +134 -0
- package/src/components/DocumentList.tsx +250 -0
- package/src/components/FieldConfigDialog.tsx +203 -0
- package/src/components/FieldPalette.tsx +81 -0
- package/src/components/FormBuilder.tsx +345 -0
- package/src/components/FormRenderer.tsx +256 -0
- package/src/components/FormViewer.tsx +84 -0
- package/src/components/PersonDocumentsWidget.tsx +220 -0
- package/src/components/TemplateSelector.tsx +91 -0
- package/src/config.ts +39 -0
- package/src/data/index.ts +3 -0
- package/src/data/mock.ts +193 -0
- package/src/data/supabase.ts +405 -0
- package/src/data/types.ts +33 -0
- package/src/document-types.ts +51 -0
- package/src/index.ts +195 -0
- package/src/lib/tenant.ts +5 -0
- package/src/locales/en.ts +75 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +75 -0
- package/src/migrations/001_frm_base.sql +138 -0
- package/src/migrations/002_document_archetype.sql +135 -0
- package/src/store.ts +167 -0
- package/src/types.ts +203 -0
- package/src/views/CustomFormsSettingsTab.tsx +115 -0
- 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} · </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
|
+
}
|