@fayz-ai/plugin-forms 0.9.0 → 0.9.2
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/README.md +3 -3
- package/dist/{FormBuilder-7SWZQKFN.js → FormBuilder-YHJHF6HZ.js} +4 -4
- package/dist/{FormBuilder-7SWZQKFN.js.map → FormBuilder-YHJHF6HZ.js.map} +1 -1
- package/dist/{chunk-W2ZNJGQC.js → chunk-RBLXIKWB.js} +221 -8
- package/dist/chunk-RBLXIKWB.js.map +1 -0
- package/dist/components/AddDocumentDropdown.d.ts +1 -1
- package/dist/components/AddDocumentDropdown.d.ts.map +1 -1
- package/dist/components/DocumentFormDialog.d.ts.map +1 -1
- package/dist/components/DocumentSkeleton.d.ts +21 -0
- package/dist/components/DocumentSkeleton.d.ts.map +1 -0
- package/dist/components/FormRenderer.d.ts.map +1 -1
- package/dist/components/FormViewer.d.ts.map +1 -1
- package/dist/{CustomFormsContext.d.ts → context.d.ts} +1 -1
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +4 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +174 -17
- package/dist/index.js.map +1 -1
- package/dist/lib/agent-tools.d.ts.map +1 -0
- package/dist/lib/document-types.d.ts.map +1 -0
- package/dist/lib/html-text.d.ts +21 -0
- package/dist/lib/html-text.d.ts.map +1 -0
- package/dist/lib/print.d.ts.map +1 -1
- package/dist/lib/timeline-source.d.ts +10 -0
- package/dist/lib/timeline-source.d.ts.map +1 -0
- 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/store.d.ts +5 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/TemplateListView.d.ts.map +1 -1
- package/package.json +5 -7
- package/dist/CustomFormsContext.d.ts.map +0 -1
- package/dist/agent-tools.d.ts.map +0 -1
- package/dist/chunk-W2ZNJGQC.js.map +0 -1
- package/dist/document-types.d.ts.map +0 -1
- package/src/CustomFormsContext.tsx +0 -28
- package/src/agent-tools.ts +0 -73
- package/src/components/AddDocumentDropdown.tsx +0 -92
- package/src/components/BuilderCanvas.tsx +0 -87
- package/src/components/BuilderField.tsx +0 -81
- package/src/components/DocumentFormDialog.tsx +0 -155
- package/src/components/DocumentList.tsx +0 -254
- package/src/components/FieldConfigDialog.tsx +0 -243
- package/src/components/FieldPalette.tsx +0 -81
- package/src/components/FormBuilder.tsx +0 -427
- package/src/components/FormRenderer.tsx +0 -256
- package/src/components/FormViewer.tsx +0 -94
- package/src/components/PersonDocumentsWidget.tsx +0 -229
- package/src/components/TemplateSelector.tsx +0 -91
- package/src/config.ts +0 -43
- package/src/data/index.ts +0 -3
- package/src/data/mock.ts +0 -193
- package/src/data/supabase.ts +0 -408
- package/src/data/tables.ts +0 -7
- package/src/data/types.ts +0 -33
- package/src/document-types.ts +0 -51
- package/src/index.ts +0 -234
- package/src/lib/person-fields.ts +0 -67
- package/src/lib/print.ts +0 -207
- package/src/lib/tenant.ts +0 -9
- package/src/locales/en.ts +0 -95
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -95
- package/src/migrations/000_plg_rename.sql +0 -21
- package/src/migrations/001_frm_base.sql +0 -174
- package/src/migrations/002_document_archetype.sql +0 -223
- package/src/migrations/003_agent_rpcs.sql +0 -174
- package/src/migrations/index.ts +0 -610
- package/src/store.ts +0 -167
- package/src/types.ts +0 -217
- package/src/views/CustomFormsSettingsTab.tsx +0 -105
- package/src/views/TemplateListView.tsx +0 -174
- /package/dist/{agent-tools.d.ts → lib/agent-tools.d.ts} +0 -0
- /package/dist/{document-types.d.ts → lib/document-types.d.ts} +0 -0
|
@@ -1,87 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import { ArrowLeft } from 'lucide-react'
|
|
3
|
-
import { useSaveBar } from '@fayz-ai/ui'
|
|
4
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
-
import { useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
|
|
6
|
-
import type { CustomFormsDataProvider } from '../data/types'
|
|
7
|
-
import type { CustomFormsStore } from '../store'
|
|
8
|
-
import type { FormTemplate, FormDocument } from '../types'
|
|
9
|
-
import { FormRenderer } from './FormRenderer'
|
|
10
|
-
import { prefillFromPerson } from '../lib/person-fields'
|
|
11
|
-
|
|
12
|
-
function generateTitle(templateName: string, personName?: string): string {
|
|
13
|
-
const date = new Date().toLocaleDateString()
|
|
14
|
-
return personName
|
|
15
|
-
? `${templateName} — ${personName} — ${date}`
|
|
16
|
-
: `${templateName} — ${date}`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface DocumentFormDialogProps {
|
|
20
|
-
template: FormTemplate | null
|
|
21
|
-
existingDocument?: FormDocument
|
|
22
|
-
personId: string
|
|
23
|
-
personName?: string
|
|
24
|
-
/** Full person record — mapped fields are pre-filled from it on new documents. */
|
|
25
|
-
person?: Record<string, unknown>
|
|
26
|
-
provider: CustomFormsDataProvider
|
|
27
|
-
store: CustomFormsStore
|
|
28
|
-
onSaved: () => void
|
|
29
|
-
onBack: () => void
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function DocumentFormDialog({
|
|
33
|
-
template: initialTemplate,
|
|
34
|
-
existingDocument,
|
|
35
|
-
personId,
|
|
36
|
-
personName,
|
|
37
|
-
person,
|
|
38
|
-
provider,
|
|
39
|
-
store,
|
|
40
|
-
onSaved,
|
|
41
|
-
onBack,
|
|
42
|
-
}: DocumentFormDialogProps) {
|
|
43
|
-
const t = useTranslation()
|
|
44
|
-
const guardDocuments = useLimitGuard('documents_month')
|
|
45
|
-
const [template, setTemplate] = useState<FormTemplate | null>(initialTemplate)
|
|
46
|
-
const [data, setData] = useState<Record<string, unknown>>(() =>
|
|
47
|
-
existingDocument
|
|
48
|
-
? existingDocument.data
|
|
49
|
-
: prefillFromPerson(initialTemplate?.schema, person),
|
|
50
|
-
)
|
|
51
|
-
const [saving, setSaving] = useState(false)
|
|
52
|
-
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (existingDocument && !template) {
|
|
55
|
-
provider.getTemplateById(existingDocument.templateId).then(setTemplate)
|
|
56
|
-
}
|
|
57
|
-
}, [existingDocument, template, provider])
|
|
58
|
-
|
|
59
|
-
// When creating from a template that arrives asynchronously, apply prefill
|
|
60
|
-
// once the schema is known (guarded so it never clobbers user edits).
|
|
61
|
-
const prefilled = React.useRef(!!initialTemplate || !!existingDocument)
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (prefilled.current || !template || existingDocument) return
|
|
64
|
-
prefilled.current = true
|
|
65
|
-
setData((prev) => prefillFromPerson(template.schema, person, prev))
|
|
66
|
-
}, [template, existingDocument, person])
|
|
67
|
-
|
|
68
|
-
const handleSave = useCallback(
|
|
69
|
-
async (status: 'draft' | 'completed') => {
|
|
70
|
-
if (!template) return
|
|
71
|
-
// Plan monthly-quota guard (client-side, before the provider call) — only
|
|
72
|
-
// on create. Opens the global UpgradeModal and aborts when the cap is hit.
|
|
73
|
-
if (!existingDocument && (await guardDocuments()) === 'blocked') return
|
|
74
|
-
setSaving(true)
|
|
75
|
-
try {
|
|
76
|
-
const autoTitle = generateTitle(template.name, personName)
|
|
77
|
-
if (existingDocument) {
|
|
78
|
-
await store.getState().updateDocument(existingDocument.id, { data, status })
|
|
79
|
-
} else {
|
|
80
|
-
await store.getState().createDocument({
|
|
81
|
-
templateId: template.id,
|
|
82
|
-
personId,
|
|
83
|
-
title: autoTitle,
|
|
84
|
-
data,
|
|
85
|
-
status,
|
|
86
|
-
})
|
|
87
|
-
invalidateLimit('documents_month')
|
|
88
|
-
}
|
|
89
|
-
onSaved()
|
|
90
|
-
} finally {
|
|
91
|
-
setSaving(false)
|
|
92
|
-
}
|
|
93
|
-
},
|
|
94
|
-
[template, existingDocument, data, personId, personName, store, onSaved, guardDocuments],
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
// Dirty detection drives the floating SaveBar. The baseline is the document's
|
|
98
|
-
// ORIGINAL saved data (empty for a brand-new document), so a new document that
|
|
99
|
-
// arrives pre-filled from the person's record counts as dirty immediately and
|
|
100
|
-
// the floating "unsaved changes" bar shows right away.
|
|
101
|
-
const snapshot = React.useRef<string | null>(null)
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
if (template && snapshot.current === null) {
|
|
104
|
-
snapshot.current = JSON.stringify(existingDocument?.data ?? {})
|
|
105
|
-
}
|
|
106
|
-
}, [template, existingDocument])
|
|
107
|
-
const dirty = snapshot.current !== null && JSON.stringify(data) !== snapshot.current
|
|
108
|
-
|
|
109
|
-
useSaveBar({
|
|
110
|
-
dirty,
|
|
111
|
-
saving,
|
|
112
|
-
onSave: () => { void handleSave('completed') },
|
|
113
|
-
onDiscard: () => onBack(),
|
|
114
|
-
saveLabel: t('customForms.saveAndComplete'),
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
if (!template) {
|
|
118
|
-
return (
|
|
119
|
-
<div className="flex items-center justify-center py-12">
|
|
120
|
-
<div className="h-5 w-5 border-2 border-primary border-t-transparent animate-spin rounded-full" />
|
|
121
|
-
</div>
|
|
122
|
-
)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
<div className="space-y-4">
|
|
127
|
-
{/* Header */}
|
|
128
|
-
<div className="flex items-center gap-3">
|
|
129
|
-
<button
|
|
130
|
-
onClick={onBack}
|
|
131
|
-
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
132
|
-
>
|
|
133
|
-
<ArrowLeft className="h-3.5 w-3.5" />
|
|
134
|
-
</button>
|
|
135
|
-
<div className="flex-1 min-w-0">
|
|
136
|
-
<h3 className="font-semibold text-sm">{template.name}</h3>
|
|
137
|
-
<p className="text-xs text-muted-foreground">
|
|
138
|
-
{personName && <span>{personName} · </span>}
|
|
139
|
-
{new Date().toLocaleDateString()}
|
|
140
|
-
</p>
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
{/* Form fields — saving is driven entirely by the floating SaveBar
|
|
145
|
-
(unsaved-changes bar), matching the rest of the CRUD detail UX. */}
|
|
146
|
-
<div className="rounded-xl border p-4">
|
|
147
|
-
<FormRenderer
|
|
148
|
-
schema={template.schema}
|
|
149
|
-
data={data}
|
|
150
|
-
onChange={setData}
|
|
151
|
-
/>
|
|
152
|
-
</div>
|
|
153
|
-
</div>
|
|
154
|
-
)
|
|
155
|
-
}
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState, useCallback, useRef } from 'react'
|
|
2
|
-
import { FileText, Archive, Eye, Upload, Image as ImageIcon, Paperclip } from 'lucide-react'
|
|
3
|
-
import { Badge } 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 { FormDocument, DocumentStatus } from '../types'
|
|
8
|
-
|
|
9
|
-
interface DocumentListProps {
|
|
10
|
-
personId: string
|
|
11
|
-
provider: CustomFormsDataProvider
|
|
12
|
-
store: CustomFormsStore
|
|
13
|
-
onView: (doc: FormDocument) => void
|
|
14
|
-
onFileDrop?: (files: File[]) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const STATUS_COLORS: Record<DocumentStatus, string> = {
|
|
18
|
-
draft: 'bg-warning-soft text-warning-soft-foreground',
|
|
19
|
-
completed: 'bg-success-soft text-success-soft-foreground',
|
|
20
|
-
signed: 'bg-info-soft text-info-soft-foreground',
|
|
21
|
-
archived: 'bg-muted text-muted-foreground',
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const KIND_ICONS: Record<string, React.ElementType> = {
|
|
25
|
-
form: FileText,
|
|
26
|
-
image: ImageIcon,
|
|
27
|
-
attachment: Paperclip,
|
|
28
|
-
prescription: FileText,
|
|
29
|
-
contract: FileText,
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function formatDateLabel(dateStr: string): string {
|
|
33
|
-
const date = new Date(dateStr)
|
|
34
|
-
const now = new Date()
|
|
35
|
-
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
36
|
-
const docDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
37
|
-
const diffDays = Math.round((today.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
38
|
-
|
|
39
|
-
if (diffDays === 0) return 'Hoje'
|
|
40
|
-
if (diffDays === 1) return 'Ontem'
|
|
41
|
-
if (diffDays < 7) return `${diffDays} dias atrás`
|
|
42
|
-
return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function groupByDate(docs: FormDocument[]): Array<{ date: string; label: string; items: FormDocument[] }> {
|
|
46
|
-
const groups: Map<string, FormDocument[]> = new Map()
|
|
47
|
-
for (const doc of docs) {
|
|
48
|
-
const dateKey = doc.createdAt.slice(0, 10)
|
|
49
|
-
if (!groups.has(dateKey)) groups.set(dateKey, [])
|
|
50
|
-
groups.get(dateKey)!.push(doc)
|
|
51
|
-
}
|
|
52
|
-
return Array.from(groups.entries())
|
|
53
|
-
.sort(([a], [b]) => b.localeCompare(a))
|
|
54
|
-
.map(([date, items]) => ({
|
|
55
|
-
date,
|
|
56
|
-
label: formatDateLabel(date),
|
|
57
|
-
items,
|
|
58
|
-
}))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function DocumentList({ personId, provider, store, onView, onFileDrop }: DocumentListProps) {
|
|
62
|
-
const t = useTranslation()
|
|
63
|
-
const [documents, setDocuments] = useState<FormDocument[]>([])
|
|
64
|
-
const [loading, setLoading] = useState(true)
|
|
65
|
-
const [isDragOver, setIsDragOver] = useState(false)
|
|
66
|
-
const dragCounter = useRef(0)
|
|
67
|
-
|
|
68
|
-
const fetchDocs = useCallback(async () => {
|
|
69
|
-
setLoading(true)
|
|
70
|
-
try {
|
|
71
|
-
const result = await provider.getDocuments({ personId })
|
|
72
|
-
// Archived docs are kept (not deleted) but drop out of the active list.
|
|
73
|
-
setDocuments(result.data.filter((d) => d.status !== 'archived'))
|
|
74
|
-
} finally {
|
|
75
|
-
setLoading(false)
|
|
76
|
-
}
|
|
77
|
-
}, [personId, provider])
|
|
78
|
-
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
const timer = setTimeout(() => fetchDocs(), 50)
|
|
81
|
-
return () => clearTimeout(timer)
|
|
82
|
-
}, [fetchDocs])
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
const unsub = store.subscribe(() => fetchDocs())
|
|
86
|
-
return unsub
|
|
87
|
-
}, [store, fetchDocs])
|
|
88
|
-
|
|
89
|
-
// Archive, not delete: a filled document is a record of what was signed —
|
|
90
|
-
// it is moved out of the active list, never destroyed.
|
|
91
|
-
const handleArchive = useCallback(async (doc: FormDocument) => {
|
|
92
|
-
if (!window.confirm(t('customForms.archiveDocumentConfirm'))) return
|
|
93
|
-
await provider.updateDocument(doc.id, { status: 'archived' })
|
|
94
|
-
setDocuments((prev) => prev.filter((d) => d.id !== doc.id))
|
|
95
|
-
}, [provider, t])
|
|
96
|
-
|
|
97
|
-
// Drag and drop handlers
|
|
98
|
-
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
99
|
-
e.preventDefault()
|
|
100
|
-
dragCounter.current++
|
|
101
|
-
setIsDragOver(true)
|
|
102
|
-
}, [])
|
|
103
|
-
|
|
104
|
-
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
105
|
-
e.preventDefault()
|
|
106
|
-
dragCounter.current--
|
|
107
|
-
if (dragCounter.current === 0) setIsDragOver(false)
|
|
108
|
-
}, [])
|
|
109
|
-
|
|
110
|
-
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
111
|
-
e.preventDefault()
|
|
112
|
-
}, [])
|
|
113
|
-
|
|
114
|
-
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
115
|
-
e.preventDefault()
|
|
116
|
-
dragCounter.current = 0
|
|
117
|
-
setIsDragOver(false)
|
|
118
|
-
const files = Array.from(e.dataTransfer.files)
|
|
119
|
-
if (files.length > 0 && onFileDrop) {
|
|
120
|
-
onFileDrop(files)
|
|
121
|
-
}
|
|
122
|
-
}, [onFileDrop])
|
|
123
|
-
|
|
124
|
-
const groups = groupByDate(documents)
|
|
125
|
-
|
|
126
|
-
if (loading) {
|
|
127
|
-
return (
|
|
128
|
-
<div className="space-y-4 py-4">
|
|
129
|
-
{Array.from({ length: 3 }).map((_, i) => (
|
|
130
|
-
<div key={i} className="flex items-center gap-3">
|
|
131
|
-
<div className="h-9 w-9 rounded-lg bg-muted animate-pulse shrink-0" />
|
|
132
|
-
<div className="flex-1 space-y-1.5">
|
|
133
|
-
<div className="h-3.5 w-2/3 bg-muted animate-pulse rounded" />
|
|
134
|
-
<div className="h-2.5 w-1/3 bg-muted animate-pulse rounded" />
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
))}
|
|
138
|
-
</div>
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return (
|
|
143
|
-
<div
|
|
144
|
-
className={`relative min-h-[200px] rounded-xl border-2 border-dashed transition-colors ${
|
|
145
|
-
isDragOver
|
|
146
|
-
? 'border-primary bg-primary/5'
|
|
147
|
-
: documents.length === 0
|
|
148
|
-
? 'border-border'
|
|
149
|
-
: 'border-transparent'
|
|
150
|
-
}`}
|
|
151
|
-
onDragEnter={handleDragEnter}
|
|
152
|
-
onDragLeave={handleDragLeave}
|
|
153
|
-
onDragOver={handleDragOver}
|
|
154
|
-
onDrop={handleDrop}
|
|
155
|
-
>
|
|
156
|
-
{/* Drag overlay */}
|
|
157
|
-
{isDragOver && (
|
|
158
|
-
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-xl bg-primary/5">
|
|
159
|
-
<Upload className="h-8 w-8 text-primary/50 mb-2" />
|
|
160
|
-
<p className="text-sm font-medium text-primary/70">Solte os arquivos aqui</p>
|
|
161
|
-
</div>
|
|
162
|
-
)}
|
|
163
|
-
|
|
164
|
-
{/* Empty state */}
|
|
165
|
-
{documents.length === 0 && !isDragOver && (
|
|
166
|
-
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
167
|
-
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-3">
|
|
168
|
-
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
169
|
-
</div>
|
|
170
|
-
<p className="text-sm font-medium">{t('customForms.noDocuments')}</p>
|
|
171
|
-
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
|
|
172
|
-
{t('customForms.noDocumentsDescription')}
|
|
173
|
-
</p>
|
|
174
|
-
<p className="text-[10px] text-muted-foreground mt-3 flex items-center gap-1">
|
|
175
|
-
<Upload className="h-3 w-3" />
|
|
176
|
-
Arraste arquivos para cá
|
|
177
|
-
</p>
|
|
178
|
-
</div>
|
|
179
|
-
)}
|
|
180
|
-
|
|
181
|
-
{/* Timeline */}
|
|
182
|
-
{groups.length > 0 && (
|
|
183
|
-
<div className="space-y-5 py-2">
|
|
184
|
-
{groups.map((group) => (
|
|
185
|
-
<div key={group.date}>
|
|
186
|
-
{/* Date header */}
|
|
187
|
-
<div className="flex items-center gap-2 mb-2">
|
|
188
|
-
<div className="h-px flex-1 bg-border" />
|
|
189
|
-
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1">
|
|
190
|
-
{group.label}
|
|
191
|
-
</span>
|
|
192
|
-
<div className="h-px flex-1 bg-border" />
|
|
193
|
-
</div>
|
|
194
|
-
|
|
195
|
-
{/* Documents for this date */}
|
|
196
|
-
<div className="space-y-1.5">
|
|
197
|
-
{group.items.map((doc) => {
|
|
198
|
-
const Icon = KIND_ICONS[doc.kind ?? 'form'] ?? FileText
|
|
199
|
-
const time = new Date(doc.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
200
|
-
|
|
201
|
-
return (
|
|
202
|
-
<div
|
|
203
|
-
key={doc.id}
|
|
204
|
-
className="group flex items-center gap-3 rounded-lg p-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
|
|
205
|
-
onClick={() => onView(doc)}
|
|
206
|
-
>
|
|
207
|
-
{/* Icon */}
|
|
208
|
-
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 shrink-0">
|
|
209
|
-
<Icon className="h-4 w-4 text-primary" />
|
|
210
|
-
</div>
|
|
211
|
-
|
|
212
|
-
{/* Content */}
|
|
213
|
-
<div className="flex-1 min-w-0">
|
|
214
|
-
<p className="text-sm font-medium truncate">
|
|
215
|
-
{doc.title ?? doc.templateName ?? 'Documento'}
|
|
216
|
-
</p>
|
|
217
|
-
<div className="flex items-center gap-1.5 mt-0.5">
|
|
218
|
-
{doc.templateName && (
|
|
219
|
-
<span className="text-[10px] text-muted-foreground">{doc.templateName}</span>
|
|
220
|
-
)}
|
|
221
|
-
<span className="text-[10px] text-muted-foreground">{time}</span>
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
{/* Status + actions */}
|
|
226
|
-
<Badge variant="secondary" className={`text-[10px] shrink-0 ${STATUS_COLORS[doc.status] ?? ''}`}>
|
|
227
|
-
{t(`customForms.status.${doc.status}`)}
|
|
228
|
-
</Badge>
|
|
229
|
-
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
230
|
-
<button
|
|
231
|
-
onClick={(e) => { e.stopPropagation(); onView(doc) }}
|
|
232
|
-
className="p-1.5 rounded-md hover:bg-muted"
|
|
233
|
-
>
|
|
234
|
-
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
|
235
|
-
</button>
|
|
236
|
-
<button
|
|
237
|
-
onClick={(e) => { e.stopPropagation(); handleArchive(doc) }}
|
|
238
|
-
className="p-1.5 rounded-md hover:bg-muted"
|
|
239
|
-
title={t('customForms.archive')}
|
|
240
|
-
>
|
|
241
|
-
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
|
|
242
|
-
</button>
|
|
243
|
-
</div>
|
|
244
|
-
</div>
|
|
245
|
-
)
|
|
246
|
-
})}
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
))}
|
|
250
|
-
</div>
|
|
251
|
-
)}
|
|
252
|
-
</div>
|
|
253
|
-
)
|
|
254
|
-
}
|