@fayz-ai/plugin-reports 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/ReportsContext.d.ts +16 -0
- package/dist/ReportsContext.d.ts.map +1 -0
- package/dist/ReportsPage.d.ts +8 -0
- package/dist/ReportsPage.d.ts.map +1 -0
- package/dist/chunk-LODXZ2SC.js +100 -0
- package/dist/chunk-LODXZ2SC.js.map +1 -0
- package/dist/chunk-ZSELW4DA.cjs +107 -0
- package/dist/chunk-ZSELW4DA.cjs.map +1 -0
- package/dist/components/ExportDropdown.d.ts +12 -0
- package/dist/components/ExportDropdown.d.ts.map +1 -0
- package/dist/components/ReportCard.d.ts +9 -0
- package/dist/components/ReportCard.d.ts.map +1 -0
- package/dist/components/ReportFilterBar.d.ts +12 -0
- package/dist/components/ReportFilterBar.d.ts.map +1 -0
- package/dist/components/ReportPagination.d.ts +11 -0
- package/dist/components/ReportPagination.d.ts.map +1 -0
- package/dist/components/ReportSummaryRow.d.ts +10 -0
- package/dist/components/ReportSummaryRow.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -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 +8 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +9 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/excel-2PDUZ55J.cjs +38 -0
- package/dist/excel-2PDUZ55J.cjs.map +1 -0
- package/dist/excel-QHUSM6IH.js +35 -0
- package/dist/excel-QHUSM6IH.js.map +1 -0
- package/dist/export/csv.d.ts +3 -0
- package/dist/export/csv.d.ts.map +1 -0
- package/dist/export/excel.d.ts +4 -0
- package/dist/export/excel.d.ts.map +1 -0
- package/dist/export/pdf.d.ts +7 -0
- package/dist/export/pdf.d.ts.map +1 -0
- package/dist/hooks/useReportData.d.ts +19 -0
- package/dist/hooks/useReportData.d.ts.map +1 -0
- package/dist/index.cjs +904 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +878 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/columns.d.ts +5 -0
- package/dist/lib/columns.d.ts.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/pdf-GB4JYUYH.cjs +40 -0
- package/dist/pdf-GB4JYUYH.cjs.map +1 -0
- package/dist/pdf-HETKN26Y.js +37 -0
- package/dist/pdf-HETKN26Y.js.map +1 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/ReportHub.d.ts +8 -0
- package/dist/views/ReportHub.d.ts.map +1 -0
- package/dist/views/ReportViewer.d.ts +9 -0
- package/dist/views/ReportViewer.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/ReportsContext.tsx +33 -0
- package/src/ReportsPage.tsx +62 -0
- package/src/components/ExportDropdown.tsx +63 -0
- package/src/components/ReportCard.tsx +38 -0
- package/src/components/ReportFilterBar.tsx +132 -0
- package/src/components/ReportPagination.tsx +59 -0
- package/src/components/ReportSummaryRow.tsx +68 -0
- package/src/data/index.ts +4 -0
- package/src/data/mock.ts +9 -0
- package/src/data/supabase.ts +200 -0
- package/src/data/types.ts +10 -0
- package/src/export/csv.ts +21 -0
- package/src/export/excel.ts +42 -0
- package/src/export/pdf.ts +47 -0
- package/src/hooks/useReportData.ts +123 -0
- package/src/index.ts +140 -0
- package/src/lib/columns.ts +130 -0
- package/src/lib/tenant.ts +3 -0
- package/src/locales/en.ts +28 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +28 -0
- package/src/types.ts +157 -0
- package/src/views/ReportHub.tsx +97 -0
- package/src/views/ReportViewer.tsx +183 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { ReportsContextProvider } from './ReportsContext'
|
|
3
|
+
import { ReportHub } from './views/ReportHub'
|
|
4
|
+
import { ReportViewer } from './views/ReportViewer'
|
|
5
|
+
import type { ResolvedReportsConfig, ReportDef } from './types'
|
|
6
|
+
import type { ReportDataProvider } from './data/types'
|
|
7
|
+
|
|
8
|
+
const HASH_BASE = '/reports'
|
|
9
|
+
|
|
10
|
+
function getReportIdFromHash(): string | null {
|
|
11
|
+
const hash = (window.location.hash.slice(1) || '/').split('?')[0]
|
|
12
|
+
if (hash.startsWith(HASH_BASE + '/') && hash.length > HASH_BASE.length + 1) {
|
|
13
|
+
return hash.slice(HASH_BASE.length + 1)
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ReportsPage({
|
|
19
|
+
config,
|
|
20
|
+
provider,
|
|
21
|
+
}: {
|
|
22
|
+
config: ResolvedReportsConfig
|
|
23
|
+
provider: ReportDataProvider
|
|
24
|
+
}) {
|
|
25
|
+
const [activeReportId, setActiveReportId] = useState<string | null>(getReportIdFromHash)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
function handler() {
|
|
29
|
+
setActiveReportId(getReportIdFromHash())
|
|
30
|
+
}
|
|
31
|
+
window.addEventListener('hashchange', handler)
|
|
32
|
+
return () => window.removeEventListener('hashchange', handler)
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const handleSelect = useCallback((report: ReportDef) => {
|
|
36
|
+
window.location.hash = `${HASH_BASE}/${report.id}`
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
const handleBack = useCallback(() => {
|
|
40
|
+
window.location.hash = HASH_BASE
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const activeReport = activeReportId
|
|
44
|
+
? config.reports.find((r) => r.id === activeReportId) ?? null
|
|
45
|
+
: null
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<ReportsContextProvider config={config} provider={provider}>
|
|
49
|
+
<div className="p-1">
|
|
50
|
+
{activeReport ? (
|
|
51
|
+
<ReportViewer
|
|
52
|
+
key={activeReport.id}
|
|
53
|
+
report={activeReport}
|
|
54
|
+
onBack={handleBack}
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<ReportHub onSelect={handleSelect} />
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
</ReportsContextProvider>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { Download } from 'lucide-react'
|
|
3
|
+
import { Button } from '@fayz-ai/ui'
|
|
4
|
+
import {
|
|
5
|
+
Dropdown,
|
|
6
|
+
DropdownTrigger,
|
|
7
|
+
DropdownContent,
|
|
8
|
+
DropdownItem,
|
|
9
|
+
} from '@fayz-ai/ui'
|
|
10
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
11
|
+
import type { ReportColumnDef } from '../types'
|
|
12
|
+
import { exportToCsv } from '../export/csv'
|
|
13
|
+
|
|
14
|
+
interface ExportDropdownProps {
|
|
15
|
+
columns: ReportColumnDef[]
|
|
16
|
+
data: Record<string, any>[]
|
|
17
|
+
filename: string
|
|
18
|
+
currency?: string
|
|
19
|
+
title?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ExportDropdown({ columns, data, filename, currency, title }: ExportDropdownProps) {
|
|
23
|
+
const t = useTranslation()
|
|
24
|
+
const [excelAvailable, setExcelAvailable] = useState(false)
|
|
25
|
+
const [pdfAvailable, setPdfAvailable] = useState(false)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
import('../export/excel').then((m) => m.isExcelAvailable()).then(setExcelAvailable).catch(() => {})
|
|
29
|
+
import('../export/pdf').then((m) => m.isPdfAvailable()).then(setPdfAvailable).catch(() => {})
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
const handleCsv = () => exportToCsv(columns, data, filename, currency)
|
|
33
|
+
|
|
34
|
+
const handleExcel = async () => {
|
|
35
|
+
const { exportToExcel } = await import('../export/excel')
|
|
36
|
+
await exportToExcel(columns, data, filename, currency)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const handlePdf = async () => {
|
|
40
|
+
const { exportToPdf } = await import('../export/pdf')
|
|
41
|
+
await exportToPdf(columns, data, filename, { title, currency })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Dropdown>
|
|
46
|
+
<DropdownTrigger asChild>
|
|
47
|
+
<Button variant="outline" size="sm">
|
|
48
|
+
<Download className="h-4 w-4 mr-1.5" />
|
|
49
|
+
{t('reports.export')}
|
|
50
|
+
</Button>
|
|
51
|
+
</DropdownTrigger>
|
|
52
|
+
<DropdownContent align="end">
|
|
53
|
+
<DropdownItem onClick={handleCsv}>{t('reports.exportCsv')}</DropdownItem>
|
|
54
|
+
{excelAvailable && (
|
|
55
|
+
<DropdownItem onClick={handleExcel}>{t('reports.exportExcel')}</DropdownItem>
|
|
56
|
+
)}
|
|
57
|
+
{pdfAvailable && (
|
|
58
|
+
<DropdownItem onClick={handlePdf}>{t('reports.exportPdf')}</DropdownItem>
|
|
59
|
+
)}
|
|
60
|
+
</DropdownContent>
|
|
61
|
+
</Dropdown>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import * as LucideIcons from 'lucide-react'
|
|
3
|
+
import { Badge } from '@fayz-ai/ui'
|
|
4
|
+
import type { ReportDef } from '../types'
|
|
5
|
+
|
|
6
|
+
interface ReportCardProps {
|
|
7
|
+
report: ReportDef
|
|
8
|
+
onClick: (report: ReportDef) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ReportCard({ report, onClick }: ReportCardProps) {
|
|
12
|
+
const IconComp = (LucideIcons as any)[report.icon] ?? LucideIcons.FileText
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
onClick={() => onClick(report)}
|
|
18
|
+
className="flex flex-col items-start gap-2 rounded-xl border bg-card p-4 text-left transition-all hover:border-primary/30 hover:shadow-md"
|
|
19
|
+
>
|
|
20
|
+
<div className="flex w-full items-start justify-between">
|
|
21
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
22
|
+
<IconComp className="h-5 w-5" />
|
|
23
|
+
</div>
|
|
24
|
+
{report.badge && (
|
|
25
|
+
<Badge variant="secondary" className="text-xs capitalize">
|
|
26
|
+
{report.badge}
|
|
27
|
+
</Badge>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<h4 className="font-medium text-sm">{report.name}</h4>
|
|
32
|
+
{report.description && (
|
|
33
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{report.description}</p>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
</button>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { DatePicker } from '@fayz-ai/ui'
|
|
3
|
+
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@fayz-ai/ui'
|
|
4
|
+
import { Input } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import type { ReportFilterDef, ReportDateRange, ReportFilterOption } from '../types'
|
|
7
|
+
|
|
8
|
+
interface ReportFilterBarProps {
|
|
9
|
+
dateRange: ReportDateRange
|
|
10
|
+
onDateRangeChange: (range: ReportDateRange) => void
|
|
11
|
+
filters: ReportFilterDef[]
|
|
12
|
+
filterValues: Record<string, any>
|
|
13
|
+
onFilterChange: (key: string, value: any) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ReportFilterBar({
|
|
17
|
+
dateRange,
|
|
18
|
+
onDateRangeChange,
|
|
19
|
+
filters,
|
|
20
|
+
filterValues,
|
|
21
|
+
onFilterChange,
|
|
22
|
+
}: ReportFilterBarProps) {
|
|
23
|
+
const t = useTranslation()
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
27
|
+
{/* Date range — always present */}
|
|
28
|
+
<div className="flex items-end gap-2">
|
|
29
|
+
<div className="space-y-1">
|
|
30
|
+
<label className="text-xs font-medium text-muted-foreground">{t('reports.from')}</label>
|
|
31
|
+
<DatePicker
|
|
32
|
+
value={dateRange.from}
|
|
33
|
+
onChange={(v) => onDateRangeChange({ ...dateRange, from: v })}
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="space-y-1">
|
|
37
|
+
<label className="text-xs font-medium text-muted-foreground">{t('reports.to')}</label>
|
|
38
|
+
<DatePicker
|
|
39
|
+
value={dateRange.to}
|
|
40
|
+
onChange={(v) => onDateRangeChange({ ...dateRange, to: v })}
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Custom filters */}
|
|
46
|
+
{filters.map((filter) => (
|
|
47
|
+
<FilterControl
|
|
48
|
+
key={filter.key}
|
|
49
|
+
filter={filter}
|
|
50
|
+
value={filterValues[filter.key]}
|
|
51
|
+
onChange={(v) => onFilterChange(filter.key, v)}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Individual filter control
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function FilterControl({
|
|
63
|
+
filter,
|
|
64
|
+
value,
|
|
65
|
+
onChange,
|
|
66
|
+
}: {
|
|
67
|
+
filter: ReportFilterDef
|
|
68
|
+
value: any
|
|
69
|
+
onChange: (value: any) => void
|
|
70
|
+
}) {
|
|
71
|
+
const [options, setOptions] = useState<ReportFilterOption[]>(
|
|
72
|
+
Array.isArray(filter.options) ? filter.options : [],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (typeof filter.options === 'function') {
|
|
77
|
+
filter.options().then(setOptions)
|
|
78
|
+
}
|
|
79
|
+
}, [filter.options])
|
|
80
|
+
|
|
81
|
+
if (filter.type === 'select') {
|
|
82
|
+
return (
|
|
83
|
+
<div className="space-y-1">
|
|
84
|
+
<label className="text-xs font-medium text-muted-foreground">{filter.label}</label>
|
|
85
|
+
<Select value={value ?? ''} onValueChange={(v) => onChange(v === '__all__' ? '' : v)}>
|
|
86
|
+
<SelectTrigger className="w-[180px]">
|
|
87
|
+
<SelectValue placeholder={filter.placeholder ?? filter.label} />
|
|
88
|
+
</SelectTrigger>
|
|
89
|
+
<SelectContent>
|
|
90
|
+
<SelectItem value="__all__">All</SelectItem>
|
|
91
|
+
{options.map((opt) => (
|
|
92
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
93
|
+
{opt.label}
|
|
94
|
+
</SelectItem>
|
|
95
|
+
))}
|
|
96
|
+
</SelectContent>
|
|
97
|
+
</Select>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (filter.type === 'text') {
|
|
103
|
+
return (
|
|
104
|
+
<div className="space-y-1">
|
|
105
|
+
<label className="text-xs font-medium text-muted-foreground">{filter.label}</label>
|
|
106
|
+
<Input
|
|
107
|
+
value={value ?? ''}
|
|
108
|
+
onChange={(e) => onChange(e.target.value)}
|
|
109
|
+
placeholder={filter.placeholder ?? filter.label}
|
|
110
|
+
className="w-[180px]"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (filter.type === 'number') {
|
|
117
|
+
return (
|
|
118
|
+
<div className="space-y-1">
|
|
119
|
+
<label className="text-xs font-medium text-muted-foreground">{filter.label}</label>
|
|
120
|
+
<Input
|
|
121
|
+
type="number"
|
|
122
|
+
value={value ?? ''}
|
|
123
|
+
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
|
124
|
+
placeholder={filter.placeholder ?? filter.label}
|
|
125
|
+
className="w-[120px]"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
3
|
+
import { Button } from '@fayz-ai/ui'
|
|
4
|
+
|
|
5
|
+
interface ReportPaginationProps {
|
|
6
|
+
page: number
|
|
7
|
+
pageSize: number
|
|
8
|
+
total: number
|
|
9
|
+
onPageChange: (page: number) => void
|
|
10
|
+
onPageSizeChange: (size: number) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ReportPagination({ page, pageSize, total, onPageChange, onPageSizeChange }: ReportPaginationProps) {
|
|
14
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
15
|
+
const from = (page - 1) * pageSize + 1
|
|
16
|
+
const to = Math.min(page * pageSize, total)
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center justify-between border-t px-4 py-3">
|
|
20
|
+
<div className="text-sm text-muted-foreground">
|
|
21
|
+
{total > 0 ? `${from}–${to} of ${total}` : 'No results'}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div className="flex items-center gap-3">
|
|
25
|
+
<select
|
|
26
|
+
value={pageSize}
|
|
27
|
+
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
|
28
|
+
className="h-8 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2 text-sm"
|
|
29
|
+
>
|
|
30
|
+
{[25, 50, 100].map((size) => (
|
|
31
|
+
<option key={size} value={size}>{size} / page</option>
|
|
32
|
+
))}
|
|
33
|
+
</select>
|
|
34
|
+
|
|
35
|
+
<div className="flex items-center gap-1">
|
|
36
|
+
<Button
|
|
37
|
+
variant="outline"
|
|
38
|
+
size="sm"
|
|
39
|
+
disabled={page <= 1}
|
|
40
|
+
onClick={() => onPageChange(page - 1)}
|
|
41
|
+
>
|
|
42
|
+
<ChevronLeft className="h-4 w-4" />
|
|
43
|
+
</Button>
|
|
44
|
+
<span className="text-sm px-2">
|
|
45
|
+
{page} / {totalPages}
|
|
46
|
+
</span>
|
|
47
|
+
<Button
|
|
48
|
+
variant="outline"
|
|
49
|
+
size="sm"
|
|
50
|
+
disabled={page >= totalPages}
|
|
51
|
+
onClick={() => onPageChange(page + 1)}
|
|
52
|
+
>
|
|
53
|
+
<ChevronRight className="h-4 w-4" />
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { ReportColumnDef } from '../types'
|
|
3
|
+
import { formatValuePlain } from '../lib/columns'
|
|
4
|
+
|
|
5
|
+
interface ReportSummaryRowProps {
|
|
6
|
+
columns: ReportColumnDef[]
|
|
7
|
+
data: Record<string, any>[]
|
|
8
|
+
currency?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ReportSummaryRow({ columns, data, currency }: ReportSummaryRowProps) {
|
|
12
|
+
const visibleCols = columns.filter((c) => c.visible !== false)
|
|
13
|
+
const hasAggregates = visibleCols.some((c) => c.aggregate)
|
|
14
|
+
if (!hasAggregates || data.length === 0) return null
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="rounded-lg border bg-muted/30 overflow-hidden">
|
|
18
|
+
<table className="w-full text-xs">
|
|
19
|
+
<tbody>
|
|
20
|
+
<tr className="font-semibold">
|
|
21
|
+
{visibleCols.map((col) => {
|
|
22
|
+
const value = computeAggregate(col, data)
|
|
23
|
+
const alignRight = col.align === 'right' || col.type === 'currency' || col.type === 'number'
|
|
24
|
+
return (
|
|
25
|
+
<td
|
|
26
|
+
key={col.key}
|
|
27
|
+
className={`px-3 py-1.5 ${alignRight ? 'text-right' : ''}`}
|
|
28
|
+
>
|
|
29
|
+
{value != null
|
|
30
|
+
? formatValuePlain(value, col, currency)
|
|
31
|
+
: col.aggregate
|
|
32
|
+
? '—'
|
|
33
|
+
: ''}
|
|
34
|
+
</td>
|
|
35
|
+
)
|
|
36
|
+
})}
|
|
37
|
+
</tr>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function computeAggregate(col: ReportColumnDef, data: Record<string, any>[]): number | null {
|
|
45
|
+
if (!col.aggregate) return null
|
|
46
|
+
|
|
47
|
+
const values = data
|
|
48
|
+
.map((row) => row[col.key])
|
|
49
|
+
.filter((v) => v != null && !isNaN(Number(v)))
|
|
50
|
+
.map(Number)
|
|
51
|
+
|
|
52
|
+
if (values.length === 0) return null
|
|
53
|
+
|
|
54
|
+
switch (col.aggregate) {
|
|
55
|
+
case 'sum':
|
|
56
|
+
return values.reduce((a, b) => a + b, 0)
|
|
57
|
+
case 'avg':
|
|
58
|
+
return values.reduce((a, b) => a + b, 0) / values.length
|
|
59
|
+
case 'count':
|
|
60
|
+
return values.length
|
|
61
|
+
case 'min':
|
|
62
|
+
return Math.min(...values)
|
|
63
|
+
case 'max':
|
|
64
|
+
return Math.max(...values)
|
|
65
|
+
default:
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/data/mock.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { getSupabaseClientOptional } from '@fayz-ai/core'
|
|
2
|
+
import type { ReportDef, ReportQueryState } from '../types'
|
|
3
|
+
import type { ReportDataProvider, ReportResult } from './types'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Case conversion
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function camelToSnake(str: string): string {
|
|
10
|
+
return str.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function snakeToCamel(str: string): string {
|
|
14
|
+
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mapRow(row: Record<string, unknown>, columnMap?: Record<string, string>): Record<string, any> {
|
|
18
|
+
const result: Record<string, unknown> = {}
|
|
19
|
+
for (const [key, value] of Object.entries(row)) {
|
|
20
|
+
const camelKey = columnMap?.[key] ?? snakeToCamel(key)
|
|
21
|
+
result[camelKey] = value
|
|
22
|
+
}
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Provider factory
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface SupabaseReportProviderConfig {
|
|
31
|
+
tenantId: string | (() => string | undefined)
|
|
32
|
+
tenantIdColumn?: string
|
|
33
|
+
schema?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createSupabaseReportProvider(config: SupabaseReportProviderConfig): ReportDataProvider {
|
|
37
|
+
const schema = config.schema ?? 'public'
|
|
38
|
+
const tenantIdCol = config.tenantIdColumn ?? 'tenant_id'
|
|
39
|
+
|
|
40
|
+
function resolveTenantId(): string | undefined {
|
|
41
|
+
return typeof config.tenantId === 'function' ? config.tenantId() : config.tenantId
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getClient() {
|
|
45
|
+
const supabase = getSupabaseClientOptional() as any
|
|
46
|
+
if (!supabase) throw new Error('Supabase client not available')
|
|
47
|
+
return schema === 'public' ? supabase : supabase.schema(schema)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async fetch(reportDef: ReportDef, query: ReportQueryState): Promise<ReportResult> {
|
|
52
|
+
const { dataSource } = reportDef
|
|
53
|
+
const columnMap = dataSource.columnMap
|
|
54
|
+
|
|
55
|
+
if (dataSource.kind === 'rpc') {
|
|
56
|
+
return fetchViaRpc(reportDef, query, getClient, resolveTenantId, columnMap)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return fetchViaView(reportDef, query, getClient, resolveTenantId, tenantIdCol, columnMap)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// View-based fetch
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async function fetchViaView(
|
|
69
|
+
reportDef: ReportDef,
|
|
70
|
+
query: ReportQueryState,
|
|
71
|
+
getClient: () => any,
|
|
72
|
+
resolveTenantId: () => string | undefined,
|
|
73
|
+
tenantIdCol: string,
|
|
74
|
+
columnMap?: Record<string, string>,
|
|
75
|
+
): Promise<ReportResult> {
|
|
76
|
+
const { dataSource } = reportDef
|
|
77
|
+
let q = getClient().from(dataSource.name).select('*', { count: 'exact' })
|
|
78
|
+
|
|
79
|
+
// Tenant scoping
|
|
80
|
+
const tenantId = resolveTenantId()
|
|
81
|
+
if (tenantId) {
|
|
82
|
+
q = q.eq(tenantIdCol, tenantId)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Date range
|
|
86
|
+
const dateCol = dataSource.dateColumn ?? 'created_at'
|
|
87
|
+
if (query.dateRange.from) {
|
|
88
|
+
q = q.gte(dateCol, query.dateRange.from)
|
|
89
|
+
}
|
|
90
|
+
if (query.dateRange.to) {
|
|
91
|
+
// For date-type columns, plain date string is sufficient.
|
|
92
|
+
// For timestamptz columns, extend to end of day.
|
|
93
|
+
const toValue = dateCol === 'created_at' || dateCol.endsWith('_at')
|
|
94
|
+
? query.dateRange.to + 'T23:59:59'
|
|
95
|
+
: query.dateRange.to
|
|
96
|
+
q = q.lte(dateCol, toValue)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Custom filters
|
|
100
|
+
for (const [key, value] of Object.entries(query.filters)) {
|
|
101
|
+
if (value == null || value === '') continue
|
|
102
|
+
const col = camelToSnake(key)
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
q = q.in(col, value)
|
|
105
|
+
} else {
|
|
106
|
+
q = q.eq(col, value)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Search
|
|
111
|
+
if (query.search) {
|
|
112
|
+
const searchableCols = reportDef.columns
|
|
113
|
+
.filter((c) => c.type === 'text' || c.type === 'email')
|
|
114
|
+
.map((c: any) => camelToSnake(c.key))
|
|
115
|
+
if (searchableCols.length > 0) {
|
|
116
|
+
const term = `%${query.search}%`
|
|
117
|
+
const orClause = searchableCols.map((col) => `${col}.ilike.${term}`).join(',')
|
|
118
|
+
q = q.or(orClause)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Sort
|
|
123
|
+
const sortCol = query.sortBy
|
|
124
|
+
? camelToSnake(query.sortBy)
|
|
125
|
+
: dataSource.defaultSort
|
|
126
|
+
? camelToSnake(dataSource.defaultSort)
|
|
127
|
+
: dateCol
|
|
128
|
+
const ascending = query.sortDir
|
|
129
|
+
? query.sortDir === 'asc'
|
|
130
|
+
: dataSource.defaultSortDir
|
|
131
|
+
? dataSource.defaultSortDir === 'asc'
|
|
132
|
+
: false
|
|
133
|
+
q = q.order(sortCol, { ascending })
|
|
134
|
+
|
|
135
|
+
// Pagination
|
|
136
|
+
const from = (query.page - 1) * query.pageSize
|
|
137
|
+
const to = from + query.pageSize - 1
|
|
138
|
+
q = q.range(from, to)
|
|
139
|
+
|
|
140
|
+
const { data, error, count } = await q
|
|
141
|
+
|
|
142
|
+
if (error) throw error
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
data: (data ?? []).map((row: any) => mapRow(row, columnMap)),
|
|
146
|
+
total: count ?? 0,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// RPC-based fetch
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async function fetchViaRpc(
|
|
155
|
+
reportDef: ReportDef,
|
|
156
|
+
query: ReportQueryState,
|
|
157
|
+
getClient: () => any,
|
|
158
|
+
resolveTenantId: () => string | undefined,
|
|
159
|
+
columnMap?: Record<string, string>,
|
|
160
|
+
): Promise<ReportResult> {
|
|
161
|
+
const { dataSource } = reportDef
|
|
162
|
+
const tenantId = resolveTenantId()
|
|
163
|
+
|
|
164
|
+
const params: Record<string, any> = {
|
|
165
|
+
p_tenant_id: tenantId,
|
|
166
|
+
p_from: query.dateRange.from,
|
|
167
|
+
p_to: query.dateRange.to,
|
|
168
|
+
p_page: query.page,
|
|
169
|
+
p_page_size: query.pageSize,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Custom filters
|
|
173
|
+
for (const [key, value] of Object.entries(query.filters)) {
|
|
174
|
+
if (value == null || value === '') continue
|
|
175
|
+
params[`p_${camelToSnake(key)}`] = value
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (query.sortBy) {
|
|
179
|
+
params.p_sort_by = camelToSnake(query.sortBy)
|
|
180
|
+
params.p_sort_dir = query.sortDir ?? 'desc'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { data, error } = await getClient().rpc(dataSource.name, params)
|
|
184
|
+
if (error) throw error
|
|
185
|
+
|
|
186
|
+
// RPC expected to return { data: [...], total: number }
|
|
187
|
+
if (data && typeof data === 'object' && 'data' in data) {
|
|
188
|
+
return {
|
|
189
|
+
data: (data.data ?? []).map((row: any) => mapRow(row, columnMap)),
|
|
190
|
+
total: data.total ?? 0,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fallback: RPC returns a plain array
|
|
195
|
+
const rows = Array.isArray(data) ? data : []
|
|
196
|
+
return {
|
|
197
|
+
data: rows.map((row: any) => mapRow(row, columnMap)),
|
|
198
|
+
total: rows.length,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReportDef, ReportQueryState } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface ReportResult {
|
|
4
|
+
data: Record<string, any>[]
|
|
5
|
+
total: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ReportDataProvider {
|
|
9
|
+
fetch(reportDef: ReportDef, query: ReportQueryState): Promise<ReportResult>
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { exportCSV } from '@fayz-ai/core'
|
|
2
|
+
import type { CSVColumn } from '@fayz-ai/core'
|
|
3
|
+
import type { ReportColumnDef } from '../types'
|
|
4
|
+
import { formatValuePlain } from '../lib/columns'
|
|
5
|
+
|
|
6
|
+
export function exportToCsv(
|
|
7
|
+
columns: ReportColumnDef[],
|
|
8
|
+
data: Record<string, any>[],
|
|
9
|
+
filename: string,
|
|
10
|
+
currency?: string,
|
|
11
|
+
): void {
|
|
12
|
+
const visibleCols = columns.filter((c) => c.visible !== false)
|
|
13
|
+
|
|
14
|
+
const csvColumns: CSVColumn[] = visibleCols.map((col) => ({
|
|
15
|
+
key: col.key,
|
|
16
|
+
label: col.label,
|
|
17
|
+
format: (value: any) => formatValuePlain(value, col, currency),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
exportCSV(csvColumns, data, filename)
|
|
21
|
+
}
|