import React, { useState, useEffect, useCallback, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { Button, Input, CustomSelect, Card, Badge, DataTable, Text, Alert, ConfirmDialog, Spinner, LoadingIndicator, Tabs, Switch, Checkbox, DropdownMenu, TextArea, Label, Separator, type Column, type SelectOption, type TabItem, type DropdownMenuItem, } from '@alga-psa/ui-kit'; // ============================================================================ // Theme Bridge - Receives theme from host app and applies CSS variables // ============================================================================ /** * Apply theme variables to the document root */ function applyTheme(vars: Record) { if (typeof document === 'undefined') return; const root = document.documentElement; Object.entries(vars).forEach(([key, value]) => { root.style.setProperty(key, value); }); } /** * Get the parent origin for postMessage */ function getParentOrigin(): string { const params = new URLSearchParams(window.location.search); const parentOrigin = params.get('parentOrigin'); if (parentOrigin) return parentOrigin; if (document.referrer) { try { return new URL(document.referrer).origin; } catch { // Invalid referrer } } return '*'; } /** * Send ready message to parent and set up theme listener */ function initializeThemeBridge() { const parentOrigin = getParentOrigin(); // Listen for theme messages from parent const handleMessage = (ev: MessageEvent) => { const data = ev.data; if (!data || typeof data !== 'object') return; // Check for Alga envelope format with theme message if (data.alga === true && data.version === '1' && data.type === 'theme') { console.log('[Extension] Received theme from host:', data.payload); applyTheme(data.payload || {}); } }; window.addEventListener('message', handleMessage); // Send ready message to parent so it knows to send theme window.parent.postMessage( { alga: true, version: '1', type: 'ready' }, parentOrigin ); console.log('[Extension] Sent ready message to parent:', parentOrigin); return () => window.removeEventListener('message', handleMessage); } // Initialize theme bridge on load if (typeof window !== 'undefined') { initializeThemeBridge(); } // ============================================================================ // Extension Proxy Bridge - Calls WASM handler via ext-proxy // ============================================================================ const ENVELOPE_VERSION = '1'; /** * IframeBridge for calling the extension's WASM handler via postMessage. * The host's iframeBridge handles 'apiproxy' messages and forwards them to ext-proxy. */ class IframeBridge { private listeners: Map void> = new Map(); private parentOrigin: string; constructor() { this.parentOrigin = getParentOrigin(); // Listen for responses from host window.addEventListener('message', (ev: MessageEvent) => { const data = ev.data; if (!data || typeof data !== 'object') return; if (data.alga !== true || data.version !== ENVELOPE_VERSION) return; // Handle apiproxy responses if (data.type === 'apiproxy_response' && data.request_id) { const listener = this.listeners.get(data.request_id); if (listener) { this.listeners.delete(data.request_id); listener(data); } } }); } /** * Call the extension's WASM handler via the proxy. * The route is relative to the extension's handler (e.g., '/reports', '/schema'). */ async callProxy(route: string, payload?: Uint8Array | null, options?: { method?: string }): Promise { return new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); // Set up listener for response const timeoutId = setTimeout(() => { this.listeners.delete(requestId); reject(new Error('Proxy request timed out')); }, 30000); this.listeners.set(requestId, (data) => { clearTimeout(timeoutId); if (data.payload?.error) { reject(new Error(data.payload.error)); } else if (data.payload?.body) { // Decode base64 response body try { const binaryString = atob(data.payload.body); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } const text = new TextDecoder().decode(bytes); resolve(JSON.parse(text)); } catch (e) { reject(new Error('Failed to decode proxy response')); } } else { resolve(data.payload as T); } }); // Encode payload to base64 if present let bodyBase64: string | undefined; if (payload) { let binaryString = ''; for (let i = 0; i < payload.length; i++) { binaryString += String.fromCharCode(payload[i]); } bodyBase64 = btoa(binaryString); } // Send apiproxy message to host window.parent.postMessage( { alga: true, version: ENVELOPE_VERSION, type: 'apiproxy', request_id: requestId, payload: { route, body: bodyBase64, // Only include method when non-POST to preserve backward compatibility // with deployed iframeBridge versions ...(options?.method && options.method !== 'POST' ? { method: options.method } : {}), }, }, this.parentOrigin ); }); } } // Initialize bridge const bridge = new IframeBridge(); /** * Map internal API paths to WASM handler routes. * e.g., '/api/v1/platform-reports' -> '/reports' * '/api/v1/platform-reports/schema' -> '/schema' */ function mapApiPathToHandlerRoute(path: string): string { // Remove the /api/v1/platform-reports prefix if present const prefix = '/api/v1/platform-reports'; if (path.startsWith(prefix)) { const suffix = path.slice(prefix.length); if (suffix === '' || suffix === '/') { return '/reports'; } if (suffix === '/schema') { return '/schema'; } if (suffix === '/access') { return '/access'; } if (suffix === '/audit' || suffix.startsWith('/audit?')) { return suffix; // Keep audit path as-is } // For report-specific routes like /report-id, /report-id/execute return `/reports${suffix}`; } // Handle feature flags prefix const ffPrefix = '/api/v1/platform-feature-flags'; if (path.startsWith(ffPrefix)) { const suffix = path.slice(ffPrefix.length); if (suffix === '' || suffix === '/') { return '/feature-flags'; } return `/feature-flags${suffix}`; } // Handle platform notifications prefix const notifPrefix = '/api/v1/platform-notifications'; if (path.startsWith(notifPrefix)) { const suffix = path.slice(notifPrefix.length); if (suffix === '' || suffix === '/') { return '/notifications'; } return `/notifications${suffix}`; } // Return as-is if it doesn't match the expected prefix return path; } /** * Call the WASM handler via the extension proxy. * This is the proper pattern: UI -> postMessage -> iframeBridge -> ext-proxy -> Runner -> WASM handler * * The deployed iframeBridge always sends POST regardless of the method field in postMessage. * We use two mechanisms to convey the intended HTTP method: * 1. __method query param: The ext-proxy reads this and overrides the POST method before * forwarding to the runner. This ensures the WASM handler sees the correct HTTP method. * 2. __action body field: Legacy disambiguation for operations on the same path. */ async function proxyApiCall( route: string, options?: { method?: string; body?: unknown } ): Promise { // Map the internal API path to the WASM handler route const handlerRoute = mapApiPathToHandlerRoute(route); const method = options?.method?.toUpperCase() || 'GET'; // For GET requests, append __method=GET to the query string so the ext-proxy // converts the POST (forced by iframeBridge) into GET. // DELETE/PUT continue to use the __action body field for disambiguation. let finalRoute = handlerRoute; if (method === 'GET') { const separator = handlerRoute.includes('?') ? '&' : '?'; finalRoute = `${handlerRoute}${separator}__method=${method}`; } // Build the body with __action field for non-GET methods let bodyData: Record | undefined; if (options?.body && typeof options.body === 'object') { bodyData = { ...(options.body as Record) }; } // Add __action field for methods that need disambiguation if (method === 'DELETE') { bodyData = bodyData || {}; bodyData.__action = 'delete'; } else if (method === 'PUT') { bodyData = bodyData || {}; bodyData.__action = 'update'; } else if (method === 'POST' && handlerRoute === '/reports') { bodyData = bodyData || {}; bodyData.__action = 'create'; } else if (method === 'POST' && handlerRoute === '/feature-flags') { bodyData = bodyData || {}; bodyData.__action = 'create'; } else if (method === 'POST' && handlerRoute === '/notifications') { bodyData = bodyData || {}; bodyData.__action = 'create'; } // Encode body if present let payload: Uint8Array | undefined; if (bodyData) { const bodyJson = JSON.stringify(bodyData); payload = new TextEncoder().encode(bodyJson); } console.log('[Extension] Calling proxy:', { route, handlerRoute: finalRoute, method, hasBody: !!payload }); try { const result = await bridge.callProxy(finalRoute, payload, { method }); console.log('[Extension] Proxy response:', result); return result; } catch (error) { console.error('[Extension] Proxy call failed:', error); throw error; } } // ============================================================================ // Types // ============================================================================ // Types interface PlatformReport { report_id: string; name: string; description: string | null; category: string | null; report_definition: ReportDefinition; is_active: boolean; created_at: string; updated_at: string; } interface ReportDefinition { id?: string; name?: string; description?: string; category?: string; version?: string; metrics: MetricDefinition[]; permissions?: { roles?: string[]; resources?: string[]; }; } interface ConditionalLabel { column: string; operator: 'lt' | 'gt' | 'lte' | 'gte' | 'eq'; value: number; tone: 'danger' | 'warning' | 'success' | 'info'; label?: string; } interface MetricDefinition { id: string; name: string; type: 'count' | 'sum' | 'avg' | 'query'; query: QueryDefinition; conditionalLabels?: ConditionalLabel[]; } interface QueryDefinition { table: string; fields: string[]; filters?: FilterDefinition[]; joins?: JoinDefinition[]; groupBy?: string[]; orderBy?: OrderByDefinition[]; limit?: number; } interface FilterDefinition { field: string; operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in'; value: unknown; } interface OrderByDefinition { field: string; direction: 'asc' | 'desc'; } type JoinType = 'inner' | 'left' | 'right' | 'full'; interface JoinCondition { left: string; right: string; operator?: string; } interface JoinDefinition { type: JoinType; table: string; on: JoinCondition[]; } interface ApiResponse { success: boolean; error?: string; data?: T; } // Audit log types - matches extension_audit_logs table interface AuditLogEntry { log_id: string; tenant: string; event_type: string; user_id: string | null; user_email: string | null; resource_type: 'report' | 'tenant' | 'user' | 'subscription' | 'notification' | null; resource_id: string | null; resource_name: string | null; workflow_id: string | null; status: 'pending' | 'completed' | 'failed' | 'running' | null; error_message: string | null; details: Record | null; ip_address: string | null; user_agent: string | null; created_at: string; } // Get host origin from referrer (the main app that embeds this iframe) function getHostOrigin(): string { // Priority: URL param > referrer > current origin const params = new URLSearchParams(window.location.search); const hostParam = params.get('host'); if (hostParam) { return hostParam; } // Use referrer (the page that embedded this iframe) if (document.referrer) { try { const referrerUrl = new URL(document.referrer); return referrerUrl.origin; } catch { // Invalid referrer URL } } // Fallback to current origin (won't work for cross-origin iframes) return window.location.origin; } // API client that calls the platform API through the internal proxy // Uses postMessage to avoid CORS issues with cross-origin iframes async function callExtensionApi(path: string, options: RequestInit = {}): Promise> { const route = `/api/v1/platform-reports${path}`; try { // Parse body from options if present let body: any = undefined; if (options.body && typeof options.body === 'string') { try { body = JSON.parse(options.body); } catch { body = options.body; } } const result = await proxyApiCall(route, { method: options.method as string || 'GET', body, }); // Normalize response format if (result && typeof result === 'object') { if ('success' in result) { return result as ApiResponse; } // If response has 'data' field, it's already the expected format if ('data' in result) { return { success: true, data: result.data as T }; } return { success: true, data: result as T }; } return { success: true, data: result as T }; } catch (error) { console.error('API call failed:', error); return { success: false, error: String(error) }; } } // API client for feature flag endpoints async function callFeatureFlagApi( path: string, options: RequestInit = {} ): Promise> { const route = `/api/v1/platform-feature-flags${path}`; try { let body: any = undefined; if (options.body && typeof options.body === 'string') { try { body = JSON.parse(options.body); } catch { body = options.body; } } const result = await proxyApiCall(route, { method: options.method as string || 'GET', body, }); if (result && typeof result === 'object') { if ('success' in result) { return result as ApiResponse; } if ('data' in result) { return { success: true, data: result.data as T }; } return { success: true, data: result as T }; } return { success: true, data: result as T }; } catch (error) { console.error('Feature Flag API call failed:', error); return { success: false, error: String(error) }; } } // API client for platform notification endpoints async function callNotificationApi( path: string, options: RequestInit = {} ): Promise> { const route = `/api/v1/platform-notifications${path}`; try { let body: any = undefined; if (options.body && typeof options.body === 'string') { try { body = JSON.parse(options.body); } catch { body = options.body; } } const result = await proxyApiCall(route, { method: options.method as string || 'GET', body, }); if (result && typeof result === 'object') { if ('success' in result) { return result as ApiResponse; } if ('data' in result) { return { success: true, data: result.data as T }; } return { success: true, data: result as T }; } return { success: true, data: result as T }; } catch (error) { console.error('Notification API call failed:', error); return { success: false, error: String(error) }; } } // Audit logger for feature flag actions async function logFeatureFlagAudit( action: string, details: Record ) { try { await proxyApiCall('/api/v1/platform-reports/access', { method: 'POST', body: { eventType: `feature_flag.${action}`, resourceType: 'feature_flag', ...details, }, }); } catch { // Silently fail - audit logging shouldn't break the app console.debug('[nineminds-control-panel] Failed to log feature flag audit:', action); } } // ============================================================================ // PostHog Feature Flag Types // ============================================================================ interface PostHogFlagProperty { key: string; type: string; value: string[]; operator: string; } interface PostHogFlagGroup { properties: PostHogFlagProperty[]; rollout_percentage: number | null; variant: string | null; } interface PostHogFeatureFlag { id: number; key: string; name: string; active: boolean; filters: { groups: PostHogFlagGroup[]; multivariate: { variants: { key: string; rollout_percentage: number }[]; } | null; }; created_at: string; tags: string[]; } // ============================================================================ // Dynamic Schema - Fetched from server (blocklist-filtered) // ============================================================================ interface TableSchema { name: string; columns: string[]; } interface SchemaResponse { tables: TableSchema[]; } // Schema cache to avoid refetching let schemaCache: TableSchema[] | null = null; let schemaPromise: Promise | null = null; /** * Fetch available tables and columns from the server. * The server filters the schema using a blocklist for security. * Uses the internal proxy to avoid CORS issues. */ async function fetchSchema(): Promise { if (schemaCache) { return schemaCache; } if (schemaPromise) { return schemaPromise; } schemaPromise = (async () => { try { const result = await proxyApiCall('/api/v1/platform-reports/schema'); // Handle the response format from proxy const data = result?.data || result; if (data?.tables) { schemaCache = data.tables; return data.tables as TableSchema[]; } if (result?.success === false) { console.error('[Schema] Failed to fetch:', result.error); return []; } console.error('[Schema] Unexpected response format:', result); return []; } catch (error) { console.error('[Schema] Fetch error:', error); return []; } })(); return schemaPromise!; } /** * Hook to load and use the dynamic schema */ function useSchema() { const [tables, setTables] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetchSchema() .then(schema => { setTables(schema); setLoading(false); }) .catch(err => { setError(String(err)); setLoading(false); }); }, []); const getTableColumns = useCallback((tableName: string): string[] => { const table = tables.find(t => t.name === tableName); return table?.columns || []; }, [tables]); const tableOptions: SelectOption[] = tables.map(t => ({ value: t.name, label: t.name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), })); return { tables, tableOptions, getTableColumns, loading, error }; } /** * Hook to load existing categories from reports */ function useCategories() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchCategories() { try { const result = await callExtensionApi(''); if (result.success && result.data) { // Extract distinct non-empty categories using Set for deduplication const allCategories = result.data .map(r => r.category) .filter((c): c is string => !!c && c.trim() !== ''); const distinctCategories = Array.from(new Set(allCategories)).sort(); setCategories(distinctCategories); } } catch (error) { console.error('[Categories] Fetch error:', error); } setLoading(false); } fetchCategories(); }, []); return { categories, loading }; } /** * Category selector component - allows selecting existing or adding new */ function CategorySelect({ value, onChange, }: { value: string; onChange: (value: string) => void; }) { const { categories, loading } = useCategories(); const [isAddingNew, setIsAddingNew] = useState(false); const [newCategory, setNewCategory] = useState(''); // Check if current value is a custom one (not in the list) const isCustomValue = value && !categories.includes(value); const handleSelectChange = (selected: string) => { if (selected === '__new__') { setIsAddingNew(true); setNewCategory(''); } else if (selected === '__none__') { setIsAddingNew(false); onChange(''); // Convert __none__ back to empty string } else { setIsAddingNew(false); onChange(selected); } }; const handleNewCategoryConfirm = () => { if (newCategory.trim()) { onChange(newCategory.trim()); setIsAddingNew(false); } }; const handleNewCategoryCancel = () => { setIsAddingNew(false); setNewCategory(''); }; if (loading) { return ; } if (isAddingNew) { return (
setNewCategory(e.target.value)} placeholder="Enter new category name..." style={{ flex: 1 }} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleNewCategoryConfirm(); } else if (e.key === 'Escape') { handleNewCategoryCancel(); } }} />
); } // Build options: existing categories + "Add new..." const options: SelectOption[] = [ { value: '__none__', label: 'No category' }, ...categories.map(c => ({ value: c, label: c })), // If current value is custom (not in list), add it as an option ...(isCustomValue ? [{ value: value, label: `${value} (custom)` }] : []), { value: '__new__', label: '+ Add new category...' }, ]; return ( ); } const OPERATORS = [ { value: 'eq', label: '=' }, { value: 'neq', label: '!=' }, { value: 'gt', label: '>' }, { value: 'gte', label: '>=' }, { value: 'lt', label: '<' }, { value: 'lte', label: '<=' }, { value: 'like', label: 'LIKE' }, ]; // Result rendering types interface ReportResult { reportId: string; reportName: string; executedAt: string; parameters: Record; metrics: Record; metadata: { version: string; category: string; executionTime: number; cacheHit: boolean; }; } // Helper: evaluate conditional label for a cell value function getConditionalBadge( value: unknown, columnKey: string, labels?: ConditionalLabel[] ): { tone: 'danger' | 'warning' | 'success' | 'info'; text: string } | null { if (!labels || labels.length === 0) return null; const numValue = Number(value); if (isNaN(numValue)) return null; for (const label of labels) { if (label.column !== '*' && label.column !== columnKey) continue; let matches = false; switch (label.operator) { case 'lt': matches = numValue < label.value; break; case 'gt': matches = numValue > label.value; break; case 'lte': matches = numValue <= label.value; break; case 'gte': matches = numValue >= label.value; break; case 'eq': matches = numValue === label.value; break; } if (matches) { return { tone: label.tone, text: label.label || `${label.operator} ${label.value}` }; } } return null; } // Stat Card for single-value metrics (counts, sums, etc.) function StatCard({ title, value, subtitle, conditionalLabels }: { title: string; value: string | number; subtitle?: string; conditionalLabels?: ConditionalLabel[]; }) { const badge = getConditionalBadge( typeof value === 'string' ? Number(value.replace(/,/g, '')) : value, 'count', conditionalLabels ); return (
{title}
{value}
{badge && {badge.text}}
{subtitle &&
{subtitle}
}
); } // Table for displaying metric rows with search and sorting function ResultsTable({ data, title, conditionalLabels }: { data: unknown[]; title: string; conditionalLabels?: ConditionalLabel[]; }) { const [search, setSearch] = useState(''); if (!Array.isArray(data) || data.length === 0) { return (

{title}

No data returned
); } // Get columns from first row const firstRow = data[0] as Record; const columnKeys = Object.keys(firstRow); // Format column header const formatHeader = (col: string): string => { return col .replace(/_/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()); }; // Format cell value for display const formatValue = (value: unknown): string => { if (value === null || value === undefined) return '—'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'number') return value.toLocaleString(); if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}T/)) { return new Date(value).toLocaleString(); } return String(value); }; // Filter data by search term const filteredData = search ? data.filter(row => { const rowStr = Object.values(row as Record) .map(v => String(v ?? '').toLowerCase()) .join(' '); return rowStr.includes(search.toLowerCase()); }) : data; // Build columns for DataTable with conditional label support const columns: Column>[] = columnKeys.map(key => ({ key: key as keyof Record & string, header: formatHeader(key), render: (row: Record) => { const badge = getConditionalBadge(row[key], key, conditionalLabels); return ( {formatValue(row[key])} {badge && {badge.text}} ); }, })); return (

{title}

setSearch(e.target.value)} style={{ width: '250px' }} />
[]} paginate defaultPageSize={10} />
); } // Bar chart for grouped count data function SimpleBarChart({ data, labelKey, valueKey, title }: { data: unknown[]; labelKey: string; valueKey: string; title: string; }) { if (!Array.isArray(data) || data.length === 0) return null; const maxValue = Math.max(...data.map(d => Number((d as Record)[valueKey]) || 0)); return (

{title}

{data.map((item, idx) => { const label = String((item as Record)[labelKey] || `Item ${idx + 1}`); const value = Number((item as Record)[valueKey]) || 0; const percentage = maxValue > 0 ? (value / maxValue) * 100 : 0; return (
{label}
{value.toLocaleString()}
); })}
); } // Intelligent results renderer function ResultsRenderer({ results, report }: { results: ReportResult; report?: PlatformReport }) { const { metrics, metadata, parameters, executedAt } = results; // Build maps from report definition const metricNames: Record = {}; const metricLabels: Record = {}; if (report?.report_definition?.metrics) { report.report_definition.metrics.forEach(m => { if (m.id && m.name) { metricNames[m.id] = m.name; } if (m.id && m.conditionalLabels?.length) { metricLabels[m.id] = m.conditionalLabels; } }); } // Helper to get display name for a metric const getMetricDisplayName = (metricId: string): string => { return metricNames[metricId] || metricId.replace(/_/g, ' ').replace(/metric /i, ''); }; // Detect if data is a single count result const isSingleCount = (data: unknown[]): boolean => { if (data.length !== 1) return false; const row = data[0] as Record; const keys = Object.keys(row); return keys.length === 1 && (keys[0] === 'count' || keys[0].endsWith('_count')); }; // Detect if data is grouped counts (for bar chart) const isGroupedCounts = (data: unknown[]): { labelKey: string; valueKey: string } | null => { if (data.length === 0) return null; const row = data[0] as Record; const keys = Object.keys(row); if (keys.length !== 2) return null; const countKey = keys.find(k => k === 'count' || k.endsWith('_count')); if (!countKey) return null; const labelKey = keys.find(k => k !== countKey); if (!labelKey) return null; return { labelKey, valueKey: countKey }; }; return (
{/* Execution metadata */}
Executed At
{new Date(executedAt).toLocaleString()}
Execution Time
{metadata.executionTime}ms
Category
{metadata.category || '—'}
Cache
{metadata.cacheHit ? 'Hit' : 'Miss'}
{/* Render each metric */} {Object.entries(metrics).map(([metricId, data]) => { // Handle error objects if (data && typeof data === 'object' && 'error' in data && (data as Record).error === true) { const errorData = data as unknown as { error: boolean; message: string; metricName: string }; return (
⚠️
Error in metric: {errorData.metricName || metricId} {errorData.message} Tip: Check that all non-aggregated columns are included in GROUP BY, or use aggregate functions like COUNT(), SUM(), etc.
); } if (!Array.isArray(data)) return null; const displayName = getMetricDisplayName(metricId); const labels = metricLabels[metricId]; // Single count value → StatCard (fit-to-content width) if (isSingleCount(data)) { const row = data[0] as Record; const countKey = Object.keys(row)[0]; return (
); } // Grouped counts → Bar chart + Table const grouped = isGroupedCounts(data); if (grouped && data.length <= 20) { return (
); } // Default → Table return ; })} {/* Raw JSON toggle */}
View Raw JSON
          {JSON.stringify(results, null, 2)}
        
); } // Components function ReportsList() { const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedReport, setSelectedReport] = useState(null); const [showCreate, setShowCreate] = useState(false); useEffect(() => { fetchReports(); }, []); async function fetchReports() { setLoading(true); setError(null); const result = await callExtensionApi(''); if (result.success && result.data) { setReports(result.data); } else { setError(result.error || 'Failed to fetch reports'); } setLoading(false); } if (loading) { return (
); } if (error) { return (
{error}
); } if (showCreate) { return ( setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchReports(); }} /> ); } if (selectedReport) { return ( setSelectedReport(null)} onRefresh={fetchReports} /> ); } const columns: Column[] = [ { key: 'name', header: 'Name', render: (row) => (
{row.name} {row.description && (
{row.description}
)}
), }, { key: 'category', header: 'Category', render: (row) => row.category ? ( {row.category} ) : null, }, { key: 'is_active', header: 'Status', render: (row) => ( {row.is_active ? 'Active' : 'Inactive'} ), }, { key: 'created_at', header: 'Created', render: (row) => ( {new Date(row.created_at).toLocaleDateString()} ), }, { key: 'report_id', header: 'Actions', sortable: false, render: (row) => ( ), }, ]; return (

Platform Reports

{reports.length === 0 ? ( No reports found. Click "Add New" to create your first report. ) : ( )}
); } function ReportDetail({ report, onBack, onRefresh, }: { report: PlatformReport; onBack: () => void; onRefresh: () => void; }) { const [executing, setExecuting] = useState(false); const [results, setResults] = useState(null); const [error, setError] = useState(null); const [isEditing, setIsEditing] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // If editing, render the EditReport component if (isEditing) { return ( setIsEditing(false)} onSave={onRefresh} /> ); } const handleExecute = async () => { setExecuting(true); setError(null); setResults(null); const result = await callExtensionApi(`/${report.report_id}/execute`, { method: 'POST', body: JSON.stringify({}), }); if (result.success && result.data) { setResults(result.data); } else { setError(result.error || 'Failed to execute report'); } setExecuting(false); }; const handleDeleteConfirmed = async () => { const result = await callExtensionApi(`/${report.report_id}`, { method: 'DELETE', }); if (result.success) { onRefresh(); onBack(); } else { setError(result.error || 'Failed to delete report'); } setShowDeleteConfirm(false); }; return (

{report.name}

{report.description && ( {report.description} )}
{report.category && ( {report.category} )} {report.is_active ? 'Active' : 'Inactive'}
Created: {new Date(report.created_at).toLocaleString()}
Updated: {new Date(report.updated_at).toLocaleString()}

Report Definition

          {JSON.stringify(report.report_definition, null, 2)}
        
{/* Delete Confirmation Dialog */} setShowDeleteConfirm(false)} />
{error && {error}} {results && (

Results

)}
); } // Metric type options for Select const METRIC_TYPE_OPTIONS: SelectOption[] = [ { value: 'count', label: 'Count' }, { value: 'sum', label: 'Sum' }, { value: 'avg', label: 'Average' }, { value: 'query', label: 'Custom Query' }, ]; // Operator options for Select const OPERATOR_OPTIONS: SelectOption[] = OPERATORS.map(op => ({ value: op.value, label: op.label })); // Join type options for Select const JOIN_TYPE_OPTIONS: SelectOption[] = [ { value: 'inner', label: 'INNER JOIN' }, { value: 'left', label: 'LEFT JOIN' }, { value: 'right', label: 'RIGHT JOIN' }, { value: 'full', label: 'FULL JOIN' }, ]; // Conditional label operator options const LABEL_OPERATOR_OPTIONS: SelectOption[] = [ { value: 'lt', label: '<' }, { value: 'lte', label: '<=' }, { value: 'gt', label: '>' }, { value: 'gte', label: '>=' }, { value: 'eq', label: '=' }, ]; // Conditional label tone options const LABEL_TONE_OPTIONS: SelectOption[] = [ { value: 'danger', label: 'Danger (Red)' }, { value: 'warning', label: 'Warning (Yellow)' }, { value: 'success', label: 'Success (Green)' }, { value: 'info', label: 'Info (Blue)' }, ]; function CreateReport({ onBack, onCreated }: { onBack?: () => void; onCreated?: () => void } = {}) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [category, setCategory] = useState(''); const [metrics, setMetrics] = useState([]); const [creating, setCreating] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); // Track which metrics are in "raw SQL" mode const [rawSqlMode, setRawSqlMode] = useState<{ [key: string]: boolean }>({}); const [rawSqlText, setRawSqlText] = useState<{ [key: string]: string }>({}); // Use dynamic schema from server const { tables, tableOptions, getTableColumns, loading: schemaLoading } = useSchema(); const addMetric = () => { // Use first available table, or empty string if schema not loaded yet const defaultTable = tables.length > 0 ? tables[0].name : ''; setMetrics([ ...metrics, { id: `metric_${Date.now()}`, name: '', type: 'count', query: { table: defaultTable, fields: ['COUNT(*) as count'], filters: [], groupBy: [], }, }, ]); }; const updateMetric = (index: number, updates: Partial) => { const newMetrics = [...metrics]; newMetrics[index] = { ...newMetrics[index], ...updates }; setMetrics(newMetrics); }; const updateQuery = (index: number, updates: Partial) => { const newMetrics = [...metrics]; newMetrics[index] = { ...newMetrics[index], query: { ...newMetrics[index].query, ...updates }, }; setMetrics(newMetrics); }; const removeMetric = (index: number) => { setMetrics(metrics.filter((_, i) => i !== index)); }; const moveMetric = (index: number, direction: 'up' | 'down') => { const newIndex = direction === 'up' ? index - 1 : index + 1; if (newIndex < 0 || newIndex >= metrics.length) return; const newMetrics = [...metrics]; [newMetrics[index], newMetrics[newIndex]] = [newMetrics[newIndex], newMetrics[index]]; setMetrics(newMetrics); }; const addFilter = (metricIndex: number) => { const newMetrics = [...metrics]; const filters = [...(newMetrics[metricIndex].query.filters || [])]; filters.push({ field: '', operator: 'eq', value: '' }); newMetrics[metricIndex].query.filters = filters; setMetrics(newMetrics); }; const updateFilter = (metricIndex: number, filterIndex: number, updates: Partial) => { const newMetrics = [...metrics]; const filters = [...(newMetrics[metricIndex].query.filters || [])]; filters[filterIndex] = { ...filters[filterIndex], ...updates }; newMetrics[metricIndex].query.filters = filters; setMetrics(newMetrics); }; const removeFilter = (metricIndex: number, filterIndex: number) => { const newMetrics = [...metrics]; newMetrics[metricIndex].query.filters = (newMetrics[metricIndex].query.filters || []) .filter((_, i) => i !== filterIndex); setMetrics(newMetrics); }; const addJoin = (metricIndex: number) => { const newMetrics = [...metrics]; const joins = [...(newMetrics[metricIndex].query.joins || [])]; const defaultTable = tables.length > 0 ? tables[0].name : ''; joins.push({ type: 'left', table: defaultTable, on: [{ left: '', right: '' }], }); newMetrics[metricIndex].query.joins = joins; setMetrics(newMetrics); }; const updateJoin = (metricIndex: number, joinIndex: number, updates: Partial) => { const newMetrics = [...metrics]; const joins = [...(newMetrics[metricIndex].query.joins || [])]; joins[joinIndex] = { ...joins[joinIndex], ...updates }; newMetrics[metricIndex].query.joins = joins; setMetrics(newMetrics); }; const updateJoinCondition = ( metricIndex: number, joinIndex: number, conditionIndex: number, updates: Partial ) => { const newMetrics = [...metrics]; const joins = [...(newMetrics[metricIndex].query.joins || [])]; const conditions = [...joins[joinIndex].on]; conditions[conditionIndex] = { ...conditions[conditionIndex], ...updates }; joins[joinIndex] = { ...joins[joinIndex], on: conditions }; newMetrics[metricIndex].query.joins = joins; setMetrics(newMetrics); }; const addJoinCondition = (metricIndex: number, joinIndex: number) => { const newMetrics = [...metrics]; const joins = [...(newMetrics[metricIndex].query.joins || [])]; joins[joinIndex] = { ...joins[joinIndex], on: [...joins[joinIndex].on, { left: '', right: '' }], }; newMetrics[metricIndex].query.joins = joins; setMetrics(newMetrics); }; const removeJoinCondition = (metricIndex: number, joinIndex: number, conditionIndex: number) => { const newMetrics = [...metrics]; const joins = [...(newMetrics[metricIndex].query.joins || [])]; joins[joinIndex] = { ...joins[joinIndex], on: joins[joinIndex].on.filter((_, i) => i !== conditionIndex), }; newMetrics[metricIndex].query.joins = joins; setMetrics(newMetrics); }; const removeJoin = (metricIndex: number, joinIndex: number) => { const newMetrics = [...metrics]; newMetrics[metricIndex].query.joins = (newMetrics[metricIndex].query.joins || []) .filter((_, i) => i !== joinIndex); setMetrics(newMetrics); }; const addLabel = (metricIndex: number) => { const newMetrics = [...metrics]; const labels = [...(newMetrics[metricIndex].conditionalLabels || [])]; labels.push({ column: '', operator: 'gte', value: 0, tone: 'warning', label: '' }); newMetrics[metricIndex].conditionalLabels = labels; setMetrics(newMetrics); }; const updateLabel = (metricIndex: number, labelIndex: number, updates: Partial) => { const newMetrics = [...metrics]; const labels = [...(newMetrics[metricIndex].conditionalLabels || [])]; labels[labelIndex] = { ...labels[labelIndex], ...updates }; newMetrics[metricIndex].conditionalLabels = labels; setMetrics(newMetrics); }; const removeLabel = (metricIndex: number, labelIndex: number) => { const newMetrics = [...metrics]; newMetrics[metricIndex].conditionalLabels = (newMetrics[metricIndex].conditionalLabels || []) .filter((_, i) => i !== labelIndex); setMetrics(newMetrics); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setCreating(true); setError(null); setSuccess(false); if (!name.trim()) { setError('Report name is required'); setCreating(false); return; } if (metrics.length === 0) { setError('At least one metric is required'); setCreating(false); return; } const reportDefinition: ReportDefinition = { id: `report_${Date.now()}`, name: name, description: description, category: category, version: '1.0.0', metrics: metrics, }; const result = await callExtensionApi('', { method: 'POST', body: JSON.stringify({ name, description: description || null, category: category || null, report_definition: reportDefinition, }), }); if (result.success) { if (onCreated) { onCreated(); } else { setSuccess(true); setName(''); setDescription(''); setCategory(''); setMetrics([]); } } else { setError(result.error || 'Failed to create report'); } setCreating(false); }; // Get all field options from base table + all joined tables (for filters) const getAllFieldOptions = (metric: MetricDefinition): SelectOption[] => { const options: SelectOption[] = []; // Base table columns if (metric.query.table) { getTableColumns(metric.query.table).forEach(col => { options.push({ value: col, label: col }); }); } // Joined table columns (with table prefix) (metric.query.joins || []).forEach(join => { if (join.table) { getTableColumns(join.table).forEach(col => { const fullCol = `${join.table}.${col}`; options.push({ value: fullCol, label: fullCol }); }); } }); return options; }; // Get all columns from base table AND all joined tables const getAllMetricColumns = (metric: MetricDefinition): { table: string; columns: string[] }[] => { const result: { table: string; columns: string[] }[] = []; // Base table columns if (metric.query.table) { result.push({ table: metric.query.table, columns: getTableColumns(metric.query.table), }); } // Joined table columns (metric.query.joins || []).forEach(join => { if (join.table) { result.push({ table: join.table, columns: getTableColumns(join.table), }); } }); return result; }; // State for table search filter const [tableSearch, setTableSearch] = useState(''); // Filtered table options based on search const filteredTableOptions = tableOptions.filter(opt => opt.label.toLowerCase().includes(tableSearch.toLowerCase()) ); return (
{onBack && ( )}

Create New Report

Basic Information

setName(e.target.value)} placeholder="e.g., Tenant User Summary" style={{ width: '100%', marginTop: '6px' }} />