@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.
Files changed (89) hide show
  1. package/dist/ReportsContext.d.ts +16 -0
  2. package/dist/ReportsContext.d.ts.map +1 -0
  3. package/dist/ReportsPage.d.ts +8 -0
  4. package/dist/ReportsPage.d.ts.map +1 -0
  5. package/dist/chunk-LODXZ2SC.js +100 -0
  6. package/dist/chunk-LODXZ2SC.js.map +1 -0
  7. package/dist/chunk-ZSELW4DA.cjs +107 -0
  8. package/dist/chunk-ZSELW4DA.cjs.map +1 -0
  9. package/dist/components/ExportDropdown.d.ts +12 -0
  10. package/dist/components/ExportDropdown.d.ts.map +1 -0
  11. package/dist/components/ReportCard.d.ts +9 -0
  12. package/dist/components/ReportCard.d.ts.map +1 -0
  13. package/dist/components/ReportFilterBar.d.ts +12 -0
  14. package/dist/components/ReportFilterBar.d.ts.map +1 -0
  15. package/dist/components/ReportPagination.d.ts +11 -0
  16. package/dist/components/ReportPagination.d.ts.map +1 -0
  17. package/dist/components/ReportSummaryRow.d.ts +10 -0
  18. package/dist/components/ReportSummaryRow.d.ts.map +1 -0
  19. package/dist/data/index.d.ts +5 -0
  20. package/dist/data/index.d.ts.map +1 -0
  21. package/dist/data/mock.d.ts +3 -0
  22. package/dist/data/mock.d.ts.map +1 -0
  23. package/dist/data/supabase.d.ts +8 -0
  24. package/dist/data/supabase.d.ts.map +1 -0
  25. package/dist/data/types.d.ts +9 -0
  26. package/dist/data/types.d.ts.map +1 -0
  27. package/dist/excel-2PDUZ55J.cjs +38 -0
  28. package/dist/excel-2PDUZ55J.cjs.map +1 -0
  29. package/dist/excel-QHUSM6IH.js +35 -0
  30. package/dist/excel-QHUSM6IH.js.map +1 -0
  31. package/dist/export/csv.d.ts +3 -0
  32. package/dist/export/csv.d.ts.map +1 -0
  33. package/dist/export/excel.d.ts +4 -0
  34. package/dist/export/excel.d.ts.map +1 -0
  35. package/dist/export/pdf.d.ts +7 -0
  36. package/dist/export/pdf.d.ts.map +1 -0
  37. package/dist/hooks/useReportData.d.ts +19 -0
  38. package/dist/hooks/useReportData.d.ts.map +1 -0
  39. package/dist/index.cjs +904 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +878 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/columns.d.ts +5 -0
  46. package/dist/lib/columns.d.ts.map +1 -0
  47. package/dist/lib/tenant.d.ts +3 -0
  48. package/dist/lib/tenant.d.ts.map +1 -0
  49. package/dist/locales/en.d.ts +2 -0
  50. package/dist/locales/en.d.ts.map +1 -0
  51. package/dist/locales/index.d.ts +2 -0
  52. package/dist/locales/index.d.ts.map +1 -0
  53. package/dist/locales/pt-BR.d.ts +2 -0
  54. package/dist/locales/pt-BR.d.ts.map +1 -0
  55. package/dist/pdf-GB4JYUYH.cjs +40 -0
  56. package/dist/pdf-GB4JYUYH.cjs.map +1 -0
  57. package/dist/pdf-HETKN26Y.js +37 -0
  58. package/dist/pdf-HETKN26Y.js.map +1 -0
  59. package/dist/types.d.ts +139 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/views/ReportHub.d.ts +8 -0
  62. package/dist/views/ReportHub.d.ts.map +1 -0
  63. package/dist/views/ReportViewer.d.ts +9 -0
  64. package/dist/views/ReportViewer.d.ts.map +1 -0
  65. package/package.json +55 -0
  66. package/src/ReportsContext.tsx +33 -0
  67. package/src/ReportsPage.tsx +62 -0
  68. package/src/components/ExportDropdown.tsx +63 -0
  69. package/src/components/ReportCard.tsx +38 -0
  70. package/src/components/ReportFilterBar.tsx +132 -0
  71. package/src/components/ReportPagination.tsx +59 -0
  72. package/src/components/ReportSummaryRow.tsx +68 -0
  73. package/src/data/index.ts +4 -0
  74. package/src/data/mock.ts +9 -0
  75. package/src/data/supabase.ts +200 -0
  76. package/src/data/types.ts +10 -0
  77. package/src/export/csv.ts +21 -0
  78. package/src/export/excel.ts +42 -0
  79. package/src/export/pdf.ts +47 -0
  80. package/src/hooks/useReportData.ts +123 -0
  81. package/src/index.ts +140 -0
  82. package/src/lib/columns.ts +130 -0
  83. package/src/lib/tenant.ts +3 -0
  84. package/src/locales/en.ts +28 -0
  85. package/src/locales/index.ts +7 -0
  86. package/src/locales/pt-BR.ts +28 -0
  87. package/src/types.ts +157 -0
  88. package/src/views/ReportHub.tsx +97 -0
  89. 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
+ }
@@ -0,0 +1,4 @@
1
+ export type { ReportDataProvider, ReportResult } from './types'
2
+ export { createSupabaseReportProvider } from './supabase'
3
+ export type { SupabaseReportProviderConfig } from './supabase'
4
+ export { createMockReportProvider } from './mock'
@@ -0,0 +1,9 @@
1
+ import type { ReportDataProvider } from './types'
2
+
3
+ export function createMockReportProvider(): ReportDataProvider {
4
+ return {
5
+ async fetch() {
6
+ return { data: [], total: 0 }
7
+ },
8
+ }
9
+ }
@@ -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
+ }