Added Messaging and Reports pages; connected Reports to n8n.

This commit is contained in:
Bruno Silva committed 2026-10-01 14:43:52 -04:00
1 parent 697d72f379
commit cb3325d6df
19 files changed
+3793 -21

No files matched your search

+13 -5
View File
@@ -33,10 +33,18 @@ graph TD
│ └── preload.js # Sandboxed script exposing restricted APIs to the renderer │ └── preload.js # Sandboxed script exposing restricted APIs to the renderer
├── src/ ├── src/
│ ├── assets/ # Client-side assets (e.g., country centroid JSON definitions) │ ├── assets/ # Client-side assets (e.g., country centroid JSON definitions)
│ ├── components/ # Reusable React UI components │ ├── components/ # Reusable UI components
│ ├── lib/ # Utility libraries (PocketBase, Juvonno, vaccine calculators)
│ ├── pages/ # Application page views and screens
│ │ ├── Dashboard.jsx # Metrics cards and Live Patient Board list │ │ ├── Dashboard.jsx # Metrics cards and Live Patient Board list
│ │ ├── Sidebar.jsx # Navigation bar and user/physician profile context │ │ ├── LiveTracking.jsx # Interactive MapLibre world map & active patient logs
│ │ └── LiveTracking.jsx # Interactive MapLibre world map & active patient logs │ │ ├── Messaging.jsx # Patient email composer and n8n webhook dispatcher
│ │ ├── Notifications.jsx# Alerts and task notifications
│ │ ├── PreTravel.jsx # Pre-travel consultations and vaccine planning
│ │ ├── Records.jsx # Immunization records and patient dossiers
│ │ ├── Reports.jsx # Clinic reporting and analytics
│ │ ├── RiskAudit.jsx # Travel health risk assessment tool
│ │ └── Trips.jsx # Active trips and itinerary management
│ ├── App.jsx # App root layout shell & page navigation controller │ ├── App.jsx # App root layout shell & page navigation controller
│ ├── index.css # CSS styles, layout grid, CSS custom properties (variables) │ ├── index.css # CSS styles, layout grid, CSS custom properties (variables)
│ └── main.jsx # React application entry point │ └── main.jsx # React application entry point
@@ -49,14 +57,14 @@ graph TD
## Core Features ## Core Features
### 1. Interactive Patient World Map ### 1. Interactive Patient World Map
Located in `src/components/LiveTracking.jsx`, this component renders patient destinations across the globe: Located in `src/pages/LiveTracking.jsx`, this component renders patient destinations across the globe:
- **Centroid Coordinates**: Destructuring ISO-2 country codes (e.g., `TH`, `GH`, `MX`) to map them to coordinates using a centroid lookup dataset (`src/assets/countries.json`). - **Centroid Coordinates**: Destructuring ISO-2 country codes (e.g., `TH`, `GH`, `MX`) to map them to coordinates using a centroid lookup dataset (`src/assets/countries.json`).
- **Interactive Markers**: Displays pulsed, color-coded rings corresponding to the patient's status. Clicking a marker centers the map using a smooth transition (`flyTo`) and displays a detailed information popup. - **Interactive Markers**: Displays pulsed, color-coded rings corresponding to the patient's status. Clicking a marker centers the map using a smooth transition (`flyTo`) and displays a detailed information popup.
- **English Label Translation**: Coerces country names on the map to display in English. - **English Label Translation**: Coerces country names on the map to display in English.
- **Visual De-cluttering**: Automatically hides non-essential map layers (continents, states, roads, cities) when loading, leaving a clinical visual representation focused strictly on the patient country destinations. - **Visual De-cluttering**: Automatically hides non-essential map layers (continents, states, roads, cities) when loading, leaving a clinical visual representation focused strictly on the patient country destinations.
### 2. Live Patient Board & Stats Dashboard ### 2. Live Patient Board & Stats Dashboard
Located in `src/components/Dashboard.jsx`, this acts as the clinic's command center: Located in `src/pages/Dashboard.jsx`, this acts as the clinic's command center:
- **Stat Cards**: Dynamic counters highlighting patient states (`In Pre-Travel`, `In Transit`, `At Risk`, `In Post-Travel`) with growth/decay indicators. - **Stat Cards**: Dynamic counters highlighting patient states (`In Pre-Travel`, `In Transit`, `At Risk`, `In Post-Travel`) with growth/decay indicators.
- **Live Board Table**: A tabular view of all patients, showing their email, scheduled departure/arrival dates, and status badges. - **Live Board Table**: A tabular view of all patients, showing their email, scheduled departure/arrival dates, and status badges.
+30 -3
View File
@@ -19,6 +19,12 @@ import {
archiveNotification, archiveNotification,
} from './pocketbase.js' } from './pocketbase.js'
import { JUVONNO_API_URL, JUVONNO_API_KEY } from './juvonno-config.js' import { JUVONNO_API_URL, JUVONNO_API_KEY } from './juvonno-config.js'
import {
AUDIT_WEBHOOK_URL,
AUDIT_API_KEY,
REPORTS_WEBHOOK_URL,
REPORTS_API_KEY,
} from './n8n-config.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -161,9 +167,6 @@ ipcMain.handle('pb:archive-notification', async (_event, id, isArchived) => {
// ─── Health Risk Audit IPC Handler (Node-side to avoid CORS) ──────────────── // ─── Health Risk Audit IPC Handler (Node-side to avoid CORS) ────────────────
ipcMain.handle('audit:run-health-audit', async (_event, payload) => { ipcMain.handle('audit:run-health-audit', async (_event, payload) => {
const AUDIT_WEBHOOK_URL = 'https://n8n.wirediv.dev/webhook/travel-health-audit'
const AUDIT_API_KEY = '63350077-643e-40d8-8d34-378495c81203'
const response = await fetch(AUDIT_WEBHOOK_URL, { const response = await fetch(AUDIT_WEBHOOK_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -208,5 +211,29 @@ ipcMain.handle('juvonno:fetch-chart', async (_event, emrId) => {
return await response.json() return await response.json()
}) })
// ─── Reports Webhook IPC Handler (Node-side to avoid CORS) ──────────────────
ipcMain.handle('reports:fetch-report', async (_event, payload) => {
const response = await fetch(REPORTS_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': REPORTS_API_KEY,
},
body: JSON.stringify(payload),
})
const text = await response.text()
if (!response.ok) {
throw new Error(`Reports webhook returned HTTP ${response.status} ${response.statusText}${text ? ` - ${text.slice(0, 150)}` : ''}`)
}
try {
return JSON.parse(text)
} catch {
throw new Error(`Invalid JSON response from webhook: ${text.slice(0, 150)}`)
}
})
+15
View File
@@ -0,0 +1,15 @@
/**
* Configuration for n8n Integration.
* Replace webhook URLs and API keys with actual values provided by n8n.
*/
// n8n API Key
export const API_KEY = '63350077-643e-40d8-8d34-378495c81203'
// Travel Health Risk Audit Webhook
export const AUDIT_WEBHOOK_URL = 'https://n8n.wirediv.dev/webhook/travel-health-audit'
export const AUDIT_API_KEY = API_KEY
// Reports & Revenue Progression Webhook
export const REPORTS_WEBHOOK_URL = 'https://n8n.wirediv.dev/webhook/ctm-reports'
export const REPORTS_API_KEY = API_KEY
+9
View File
@@ -32,6 +32,7 @@ const ALLOWED_SEND_CHANNELS = [
'pb:archive-notification', 'pb:archive-notification',
'audit:run-health-audit', 'audit:run-health-audit',
'juvonno:fetch-chart', 'juvonno:fetch-chart',
'reports:fetch-report',
] ]
const ALLOWED_RECEIVE_CHANNELS = [ const ALLOWED_RECEIVE_CHANNELS = [
@@ -56,6 +57,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pb:archive-notification', 'pb:archive-notification',
'audit:run-health-audit', 'audit:run-health-audit',
'juvonno:fetch-chart', 'juvonno:fetch-chart',
'reports:fetch-report',
] ]
// ─── Exposed API ────────────────────────────────────────────────────────── // ─── Exposed API ──────────────────────────────────────────────────────────
@@ -95,6 +97,13 @@ contextBridge.exposeInMainWorld('api', {
fetchChart: (emrId) => ipcRenderer.invoke('juvonno:fetch-chart', emrId), fetchChart: (emrId) => ipcRenderer.invoke('juvonno:fetch-chart', emrId),
}, },
/**
* Reports helper methods
*/
reports: {
fetchReport: (payload) => ipcRenderer.invoke('reports:fetch-report', payload),
},
/** /**
* Send a one-way message to the main process. * Send a one-way message to the main process.
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS * @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
+32 -2
View File
@@ -1,18 +1,20 @@
{ {
"name": "ctm-concierge", "name": "ctm-concierge",
"version": "1.1.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ctm-concierge", "name": "ctm-concierge",
"version": "1.1.0", "version": "1.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"lucide-react": "^1.3.0", "lucide-react": "^1.3.0",
"maplibre-gl": "^5.24.0", "maplibre-gl": "^5.24.0",
"pocketbase": "^0.27.0", "pocketbase": "^0.27.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-map-gl": "^8.1.1" "react-map-gl": "^8.1.1"
}, },
@@ -1244,6 +1246,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@malept/cross-spawn-promise": { "node_modules/@malept/cross-spawn-promise": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
@@ -2969,6 +2977,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@@ -6124,6 +6144,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+3 -1
View File
@@ -16,10 +16,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"lucide-react": "^1.3.0", "lucide-react": "^1.3.0",
"maplibre-gl": "^5.24.0", "maplibre-gl": "^5.24.0",
"pocketbase": "^0.27.0", "pocketbase": "^0.27.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-map-gl": "^8.1.1" "react-map-gl": "^8.1.1"
}, },
@@ -58,4 +60,4 @@
"icon": "icons/icon.icns" "icon": "icons/icon.icns"
} }
} }
} }
+11 -7
View File
@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import Sidebar from './components/Sidebar.jsx' import Sidebar from './components/Sidebar.jsx'
import Dashboard from './components/Dashboard.jsx' import Dashboard from './pages/Dashboard.jsx'
import LiveTracking from './components/LiveTracking.jsx' import LiveTracking from './pages/LiveTracking.jsx'
import Trips from './components/Trips.jsx' import Trips from './pages/Trips.jsx'
import Records from './components/Records.jsx' import PreTravel from './pages/PreTravel.jsx'
import RiskAudit from './components/RiskAudit.jsx' import Records from './pages/Records.jsx'
import PreTravel from './components/PreTravel.jsx' import RiskAudit from './pages/RiskAudit.jsx'
import Notifications from './components/Notifications.jsx' import Messaging from './pages/Messaging.jsx'
import Reports from './pages/Reports.jsx'
import Notifications from './pages/Notifications.jsx'
import { fetchNotifications } from './lib/pocketbase.js' import { fetchNotifications } from './lib/pocketbase.js'
/** /**
@@ -39,6 +41,8 @@ export default function App() {
case 'records': return <Records /> case 'records': return <Records />
case 'risk-audit': return <RiskAudit /> case 'risk-audit': return <RiskAudit />
case 'pre-travel': return <PreTravel /> case 'pre-travel': return <PreTravel />
case 'messaging': return <Messaging />
case 'reports': return <Reports />
case 'notifications': case 'notifications':
return ( return (
<Notifications <Notifications
+4 -2
View File
@@ -8,7 +8,8 @@ import {
Settings, Settings,
MapPin, MapPin,
Briefcase, Briefcase,
ShieldAlert ShieldAlert,
MessageCircle
} from 'lucide-react' } from 'lucide-react'
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg' import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
import { fetchEntries } from '../lib/pocketbase.js' import { fetchEntries } from '../lib/pocketbase.js'
@@ -23,7 +24,8 @@ const NAV_ITEMS = [
const TOOLS_ITEMS = [ const TOOLS_ITEMS = [
{ id: 'risk-audit', label: 'Risk Audit', icon: ShieldAlert, badge: null, disabled: false }, { id: 'risk-audit', label: 'Risk Audit', icon: ShieldAlert, badge: null, disabled: false },
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true }, { id: 'messaging', label: 'Messaging', icon: MessageCircle, badge: null, disabled: false },
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: false },
] ]
const SYSTEM_ITEMS = [ const SYSTEM_ITEMS = [
+1383
View File
File diff suppressed because it is too large. Load diff
+446
View File
@@ -0,0 +1,446 @@
/**
* Utility functions and data transformers for the CTM Reports module.
*/
/**
* Format numbers as localized currency ($1,234.56).
*/
export function formatCurrency(value, digits = 2) {
const num = Number(value) || 0
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(num)
}
/**
* Format number with thousand separators.
*/
export function formatNumber(value) {
const num = Number(value) || 0
return new Intl.NumberFormat('en-US').format(num)
}
/**
* Format percentages (e.g. +12.4% or -3.2%).
*/
export function formatPercentage(value, showSign = true) {
if (value === null || value === undefined || isNaN(value)) return '—'
const sign = showSign && value > 0 ? '+' : ''
return `${sign}${value.toFixed(1)}%`
}
/**
* Format 'YYYY-MM' to readable month label (e.g. 'Feb 2026').
*/
export function formatMonthKey(monthKey) {
if (!monthKey || !monthKey.includes('-')) return monthKey
const [year, month] = monthKey.split('-')
const date = new Date(Number(year), Number(month) - 1, 1)
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
}
/**
* Format a Date object to YYYY-MM-DD string using local calendar day.
*/
export function formatDateIso(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/**
* Compute dynamic date ranges based on the current date.
* Completed previous months are used so unfinished months are not included.
*
* @param {'ytd' | 'last-3-months' | 'last-month' | 'all-year' | 'all2026'} preset
* @param {Date} [referenceDate=new Date()]
* @returns {{ startDate: string, endDate: string }}
*/
export function getDatePresetRange(preset, referenceDate = new Date()) {
const year = referenceDate.getFullYear()
const month = referenceDate.getMonth() // 0-indexed (Jan = 0)
// Last day of the previous completed month (day 0 of current month)
const prevMonthEnd = new Date(year, month, 0)
switch (preset) {
case 'last-month': {
// 1st day of previous month to last day of previous month
const prevMonthStart = new Date(year, month - 1, 1)
return {
startDate: formatDateIso(prevMonthStart),
endDate: formatDateIso(prevMonthEnd),
}
}
case 'last-3-months': {
// 3 completed months prior to current month
const threeMonthsStart = new Date(year, month - 3, 1)
return {
startDate: formatDateIso(threeMonthsStart),
endDate: formatDateIso(prevMonthEnd),
}
}
case 'ytd': {
// If January, return the previous full completed year
if (month === 0) {
return {
startDate: `${year - 1}-01-01`,
endDate: `${year - 1}-12-31`,
}
}
return {
startDate: `${year}-01-01`,
endDate: formatDateIso(prevMonthEnd),
}
}
case 'all-year':
case 'all2026':
default: {
return {
startDate: `${year}-01-01`,
endDate: `${year}-12-31`,
}
}
}
}
/**
* Group raw invoice list by month (YYYY-MM) and compute cumulative progression.
*
* @param {Array|Object} rawData - Response from n8n webhook
* @returns {Object} Aggregated progression data and summary metrics
*/
export function aggregateMonthlyProgression(rawData) {
const reportObj = Array.isArray(rawData) ? rawData[0] || {} : rawData || {}
const invoiceList = Array.isArray(reportObj.list) ? reportObj.list : []
// Month bucket map: 'YYYY-MM' -> { gross, net, tax, count, items }
const monthMap = {}
for (const inv of invoiceList) {
if (!inv.date) continue
const monthKey = String(inv.date).substring(0, 7) // 'YYYY-MM'
if (!monthMap[monthKey]) {
monthMap[monthKey] = {
monthKey,
gross: 0,
net: 0,
tax: 0,
count: 0,
items: [],
}
}
const gross = Number(inv.amount) || 0
const tax = Number(inv.tax1) || 0
const net = inv.preTaxAmount !== undefined ? Number(inv.preTaxAmount) : (gross - tax)
monthMap[monthKey].gross += gross
monthMap[monthKey].net += net
monthMap[monthKey].tax += tax
monthMap[monthKey].count += 1
monthMap[monthKey].items.push(inv)
}
// Sort months chronologically
const sortedMonthKeys = Object.keys(monthMap).sort()
let runningCumulativeGross = 0
let runningCumulativeNet = 0
let runningCumulativeTax = 0
let runningCumulativeCount = 0
const monthlyProgression = sortedMonthKeys.map((key, index) => {
const current = monthMap[key]
const roundedGross = Math.round(current.gross * 100) / 100
const roundedNet = Math.round(current.net * 100) / 100
const roundedTax = Math.round(current.tax * 100) / 100
runningCumulativeGross += roundedGross
runningCumulativeNet += roundedNet
runningCumulativeTax += roundedTax
runningCumulativeCount += current.count
// Calculate Month-over-Month (MoM) growth
let momGrossPercent = null
let momNetPercent = null
if (index > 0) {
const prevKey = sortedMonthKeys[index - 1]
const prev = monthMap[prevKey]
if (prev.gross > 0) {
momGrossPercent = ((current.gross - prev.gross) / prev.gross) * 100
}
if (prev.net > 0) {
momNetPercent = ((current.net - prev.net) / prev.net) * 100
}
}
return {
key,
monthKey: key,
label: formatMonthKey(key),
shortLabel: formatMonthKey(key).split(' ')[0],
gross: roundedGross,
net: roundedNet,
tax: roundedTax,
count: current.count,
avgTicket: current.count > 0 ? Math.round((current.gross / current.count) * 100) / 100 : 0,
cumulativeGross: Math.round(runningCumulativeGross * 100) / 100,
cumulativeNet: Math.round(runningCumulativeNet * 100) / 100,
cumulativeTax: Math.round(runningCumulativeTax * 100) / 100,
cumulativeCount: runningCumulativeCount,
growthPercent: momGrossPercent,
growthNetPercent: momNetPercent,
momGrossPercent,
momNetPercent,
}
})
// Top-level totals fallback to calculated sums if not provided by root
const totalGross =
reportObj.totalGross !== undefined
? Number(reportObj.totalGross)
: Math.round(runningCumulativeGross * 100) / 100
const totalNet =
reportObj.totalNetWithoutTax !== undefined
? Number(reportObj.totalNetWithoutTax)
: Math.round(runningCumulativeNet * 100) / 100
const totalTax =
reportObj.totalTax !== undefined
? Number(reportObj.totalTax)
: Math.round(runningCumulativeTax * 100) / 100
const invoiceCount =
reportObj.invoiceCount !== undefined
? Number(reportObj.invoiceCount)
: runningCumulativeCount
const activePeriodsCount = monthlyProgression.length
const avgPeriodGross = activePeriodsCount > 0 ? totalGross / activePeriodsCount : 0
const avgPeriodNet = activePeriodsCount > 0 ? totalNet / activePeriodsCount : 0
const avgTicket = invoiceCount > 0 ? totalGross / invoiceCount : 0
return {
raw: reportObj,
summary: {
totalGross,
totalNet,
totalTax,
invoiceCount,
activePeriodsCount,
activeMonthsCount: activePeriodsCount,
avgPeriodGross,
avgMonthlyGross: avgPeriodGross,
avgPeriodNet,
avgMonthlyNet: avgPeriodNet,
avgTicket,
},
progression: monthlyProgression,
monthlyProgression,
}
}
/**
* Format 'YYYY-MM-DD' to readable day label (e.g. 'Sep 14').
*/
export function formatDayKey(dayKey) {
if (!dayKey || !dayKey.includes('-')) return dayKey
const [year, month, day] = dayKey.split('-')
const date = new Date(Number(year), Number(month) - 1, Number(day))
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
/**
* Group raw invoice list by active days (YYYY-MM-DD) and compute cumulative progression.
* Only days with active invoices are included.
*
* @param {Array|Object} rawData - Response from n8n webhook
* @returns {Object} Aggregated progression data and summary metrics
*/
export function aggregateDailyProgression(rawData) {
const reportObj = Array.isArray(rawData) ? rawData[0] || {} : rawData || {}
const invoiceList = Array.isArray(reportObj.list) ? reportObj.list : []
// Day bucket map: 'YYYY-MM-DD' -> { gross, net, tax, count, items }
const dayMap = {}
for (const inv of invoiceList) {
if (!inv.date) continue
const dayKey = String(inv.date).substring(0, 10) // 'YYYY-MM-DD'
if (!dayMap[dayKey]) {
dayMap[dayKey] = {
dayKey,
gross: 0,
net: 0,
tax: 0,
count: 0,
items: [],
}
}
const gross = Number(inv.amount) || 0
const tax = Number(inv.tax1) || 0
const net = inv.preTaxAmount !== undefined ? Number(inv.preTaxAmount) : (gross - tax)
dayMap[dayKey].gross += gross
dayMap[dayKey].net += net
dayMap[dayKey].tax += tax
dayMap[dayKey].count += 1
dayMap[dayKey].items.push(inv)
}
// Sort active days chronologically
const sortedDayKeys = Object.keys(dayMap).sort()
let runningCumulativeGross = 0
let runningCumulativeNet = 0
let runningCumulativeTax = 0
let runningCumulativeCount = 0
const dailyProgression = sortedDayKeys.map((key, index) => {
const current = dayMap[key]
const roundedGross = Math.round(current.gross * 100) / 100
const roundedNet = Math.round(current.net * 100) / 100
const roundedTax = Math.round(current.tax * 100) / 100
runningCumulativeGross += roundedGross
runningCumulativeNet += roundedNet
runningCumulativeTax += roundedTax
runningCumulativeCount += current.count
// Day-over-Day (DoD) growth
let dodGrossPercent = null
let dodNetPercent = null
if (index > 0) {
const prevKey = sortedDayKeys[index - 1]
const prev = dayMap[prevKey]
if (prev.gross > 0) {
dodGrossPercent = ((current.gross - prev.gross) / prev.gross) * 100
}
if (prev.net > 0) {
dodNetPercent = ((current.net - prev.net) / prev.net) * 100
}
}
return {
key,
dayKey: key,
label: formatDayKey(key),
shortLabel: formatDayKey(key),
gross: roundedGross,
net: roundedNet,
tax: roundedTax,
count: current.count,
avgTicket: current.count > 0 ? Math.round((current.gross / current.count) * 100) / 100 : 0,
cumulativeGross: Math.round(runningCumulativeGross * 100) / 100,
cumulativeNet: Math.round(runningCumulativeNet * 100) / 100,
cumulativeTax: Math.round(runningCumulativeTax * 100) / 100,
cumulativeCount: runningCumulativeCount,
growthPercent: dodGrossPercent,
growthNetPercent: dodNetPercent,
dodGrossPercent,
dodNetPercent,
}
})
const totalGross =
reportObj.totalGross !== undefined
? Number(reportObj.totalGross)
: Math.round(runningCumulativeGross * 100) / 100
const totalNet =
reportObj.totalNetWithoutTax !== undefined
? Number(reportObj.totalNetWithoutTax)
: Math.round(runningCumulativeNet * 100) / 100
const totalTax =
reportObj.totalTax !== undefined
? Number(reportObj.totalTax)
: Math.round(runningCumulativeTax * 100) / 100
const invoiceCount =
reportObj.invoiceCount !== undefined
? Number(reportObj.invoiceCount)
: runningCumulativeCount
const activePeriodsCount = dailyProgression.length
const avgPeriodGross = activePeriodsCount > 0 ? totalGross / activePeriodsCount : 0
const avgPeriodNet = activePeriodsCount > 0 ? totalNet / activePeriodsCount : 0
const avgTicket = invoiceCount > 0 ? totalGross / invoiceCount : 0
return {
raw: reportObj,
summary: {
totalGross,
totalNet,
totalTax,
invoiceCount,
activePeriodsCount,
activeDaysCount: activePeriodsCount,
avgPeriodGross,
avgDailyGross: avgPeriodGross,
avgPeriodNet,
avgDailyNet: avgPeriodNet,
avgTicket,
},
progression: dailyProgression,
dailyProgression,
}
}
/**
* Unified progression aggregator based on active display mode ('monthly' or 'daily').
*/
export function aggregateReportData(rawData, mode = 'monthly') {
if (mode === 'daily') {
return aggregateDailyProgression(rawData)
}
return aggregateMonthlyProgression(rawData)
}
/**
* Execute report fetch via Electron IPC bridge if available, or direct fetch fallback.
*/
export async function fetchReportData({ startDate, endDate, status }) {
const payload = {
start_date: startDate,
end_date: endDate,
status: status || 'paid',
}
// Check Electron IPC Bridge first (delegates to electron/main/n8n-config.js)
if (typeof window !== 'undefined' && window.api?.reports?.fetchReport) {
return await window.api.reports.fetchReport(payload)
}
// Fallback to browser fetch (Vite development or standalone)
const response = await fetch('https://n8n.wirediv.dev/webhook/ctm-reports', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': '63350077-643e-40d8-8d34-378495c81203',
},
body: JSON.stringify(payload),
})
const text = await response.text()
if (!response.ok) {
throw new Error(`n8n webhook error: HTTP ${response.status} ${response.statusText}${text ? ` - ${text.slice(0, 150)}` : ''}`)
}
try {
return JSON.parse(text)
} catch {
throw new Error(`Invalid JSON response from webhook: ${text.slice(0, 150)}`)
}
}
File renamed without changes.
File renamed without changes.
File diff suppressed because it is too large. Load diff
File renamed without changes.
File renamed without changes.
File renamed without changes.
+794
View File
@@ -0,0 +1,794 @@
import React, { useState, useMemo } from 'react'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
import { Chart } from 'react-chartjs-2'
import {
BarChart3,
TrendingUp,
Calendar,
DollarSign,
Receipt,
RotateCw,
Filter,
ArrowUpRight,
ArrowDownRight,
Download,
Check,
AlertTriangle,
SlidersHorizontal,
} from 'lucide-react'
import {
formatCurrency,
formatNumber,
formatPercentage,
aggregateReportData,
fetchReportData,
getDatePresetRange,
} from '../lib/reportsUtils.js'
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Filler
)
export default function Reports() {
// ── Query Mode & Presets State ──────────────────────────────────────────────
const [mode, setMode] = useState('monthly') // 'monthly' | 'daily'
const [activePreset, setActivePreset] = useState(null)
const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false)
// ── Date & Status Parameters ────────────────────────────────────────────────
const defaultRange = useMemo(() => getDatePresetRange('ytd'), [])
const [startDate, setStartDate] = useState(defaultRange.startDate)
const [endDate, setEndDate] = useState(defaultRange.endDate)
const [status, setStatus] = useState('paid')
// ── Data & UI State ─────────────────────────────────────────────────────────
const [rawResponse, setRawResponse] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [chartView, setChartView] = useState('combo') // 'combo' | 'period' | 'cumulative'
const [copiedNotification, setCopiedNotification] = useState(false)
// ── Aggregate Data via Progression Engine ───────────────────────────────────
const { summary, progression } = useMemo(() => {
if (!rawResponse) {
return {
summary: {
totalGross: 0,
totalNet: 0,
totalTax: 0,
invoiceCount: 0,
activePeriodsCount: 0,
avgPeriodGross: 0,
avgPeriodNet: 0,
avgTicket: 0,
},
progression: [],
}
}
return aggregateReportData(rawResponse, mode)
}, [rawResponse, mode])
// ── Core Fetch Function ─────────────────────────────────────────────────────
async function runQuery({ sDate, eDate, stat, queryMode }) {
setLoading(true)
setError(null)
try {
const data = await fetchReportData({
startDate: sDate,
endDate: eDate,
status: stat,
})
setRawResponse(data)
setMode(queryMode)
} catch (err) {
setError(err.message || 'Failed to connect to reports webhook.')
} finally {
setLoading(false)
}
}
// ── Preset Selection (Monthly Mode & Immediate Auto-Fetch) ──────────────────
function handlePresetClick(presetKey) {
setActivePreset(presetKey)
setIsCustomRangeOpen(false)
const range = getDatePresetRange(presetKey)
if (range) {
setStartDate(range.startDate)
setEndDate(range.endDate)
setStatus('paid')
runQuery({
sDate: range.startDate,
eDate: range.endDate,
stat: 'paid',
queryMode: 'monthly',
})
}
}
// ── Custom Range Toggle & Submission (Daily Mode) ───────────────────────────
function toggleCustomRange() {
setIsCustomRangeOpen((prev) => {
const next = !prev
if (next) {
setActivePreset(null)
}
return next
})
}
function handleCustomSubmit(e) {
e.preventDefault()
runQuery({
sDate: startDate,
eDate: endDate,
stat: status,
queryMode: 'daily',
})
}
// ── Export CSV Functionality ───────────────────────────────────────────────
function exportCsv() {
if (!rawResponse || progression.length === 0) return
const periodLabel = mode === 'daily' ? 'Date' : 'Month'
const growthLabel = mode === 'daily' ? 'DoD Change' : 'MoM Change'
const headers = [
periodLabel,
'Invoices',
'Net (Pre-Tax)',
'Tax',
'Gross Revenue',
'Cumulative Total',
growthLabel,
]
const rows = progression.map((row) => {
const growthVal = row.growthPercent !== null ? formatPercentage(row.growthPercent, true) : 'Base'
return [
`"${row.label}"`,
row.count,
row.net.toFixed(2),
row.tax.toFixed(2),
row.gross.toFixed(2),
row.cumulativeGross.toFixed(2),
`"${growthVal}"`,
]
})
// Add summary row at the bottom
rows.push([
`"TOTAL (${summary.activePeriodsCount} ${mode === 'daily' ? 'days' : 'months'})"`,
summary.invoiceCount,
summary.totalNet.toFixed(2),
summary.totalTax.toFixed(2),
summary.totalGross.toFixed(2),
summary.totalGross.toFixed(2),
'""',
])
const csvContent = [headers.join(','), ...rows.map((r) => r.join(','))].join('\r\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `ctm-revenue-progression-${mode}-${startDate}-to-${endDate}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
setCopiedNotification(true)
setTimeout(() => setCopiedNotification(false), 2000)
}
// ── Chart.js Configuration ─────────────────────────────────────────────────
const chartLabels = progression.map((p) => p.label)
const chartData = useMemo(() => {
const grossData = progression.map((p) => p.gross)
const netData = progression.map((p) => p.net)
const cumulativeGrossData = progression.map((p) => p.cumulativeGross)
const periodGrossLabel = mode === 'daily' ? 'Daily Gross Revenue' : 'Monthly Gross Revenue'
const periodNetLabel = mode === 'daily' ? 'Daily Net (Pre-Tax)' : 'Monthly Net (Pre-Tax)'
if (chartView === 'period') {
return {
labels: chartLabels,
datasets: [
{
type: 'bar',
label: periodGrossLabel,
data: grossData,
backgroundColor: 'rgba(59, 158, 255, 0.75)',
borderColor: '#3b9eff',
borderWidth: 1.5,
borderRadius: 6,
yAxisID: 'y',
},
{
type: 'bar',
label: periodNetLabel,
data: netData,
backgroundColor: 'rgba(16, 185, 129, 0.65)',
borderColor: '#10b981',
borderWidth: 1.5,
borderRadius: 6,
yAxisID: 'y',
},
],
}
}
if (chartView === 'cumulative') {
return {
labels: chartLabels,
datasets: [
{
type: 'line',
label: 'Cumulative Revenue Progression',
data: cumulativeGrossData,
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.15)',
borderWidth: 3,
pointBackgroundColor: '#8b5cf6',
pointBorderColor: '#fff',
pointRadius: mode === 'daily' ? 3.5 : 5,
pointHoverRadius: mode === 'daily' ? 5.5 : 7,
fill: true,
tension: 0.35,
yAxisID: 'y',
},
],
}
}
// Default 'combo' view
return {
labels: chartLabels,
datasets: [
{
type: 'line',
label: 'Cumulative Revenue Progression',
data: cumulativeGrossData,
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.08)',
borderWidth: 3,
pointBackgroundColor: '#8b5cf6',
pointBorderColor: '#ffffff',
pointRadius: mode === 'daily' ? 3.5 : 4.5,
pointHoverRadius: mode === 'daily' ? 5.5 : 6.5,
tension: 0.3,
yAxisID: 'yCumulative',
},
{
type: 'bar',
label: periodGrossLabel,
data: grossData,
backgroundColor: 'rgba(59, 158, 255, 0.8)',
borderColor: '#3b9eff',
borderWidth: 1.5,
borderRadius: 6,
yAxisID: 'y',
},
{
type: 'bar',
label: periodNetLabel,
data: netData,
backgroundColor: 'rgba(16, 185, 129, 0.65)',
borderColor: '#10b981',
borderWidth: 1.5,
borderRadius: 6,
yAxisID: 'y',
},
],
}
}, [progression, chartLabels, chartView, mode])
const chartOptions = useMemo(() => {
const isCombo = chartView === 'combo'
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
color: '#8fa3b8',
font: { family: 'Inter', size: 12, weight: '500' },
boxWidth: 14,
usePointStyle: true,
},
},
tooltip: {
backgroundColor: '#111827',
titleColor: '#f0f4f8',
bodyColor: '#8fa3b8',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 12,
boxPadding: 6,
callbacks: {
label(context) {
const val = context.parsed.y !== null ? formatCurrency(context.parsed.y) : ''
return `${context.dataset.label}: ${val}`
},
},
},
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.04)',
},
ticks: {
color: '#8fa3b8',
font: { family: 'Inter', size: 12 },
maxRotation: mode === 'daily' ? 45 : 0,
autoSkip: mode === 'daily',
maxTicksLimit: mode === 'daily' ? 24 : 12,
},
},
y: {
type: 'linear',
display: true,
position: 'left',
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#8fa3b8',
font: { family: 'Inter', size: 11 },
callback(val) {
return '$' + val
},
},
},
...(isCombo
? {
yCumulative: {
type: 'linear',
display: true,
position: 'right',
grid: {
drawOnChartArea: false,
},
ticks: {
color: '#a78bfa',
font: { family: 'Inter', size: 11 },
callback(val) {
return '$' + val
},
},
},
}
: {}),
},
}
}, [chartView, mode])
return (
<section className="reports-page" aria-label="Financial Reports">
{/* ── Page Header ──────────────────────────────────────────────────────── */}
<header className="reports-header">
<div>
<p className="dashboard-greeting">Financial Reports</p>
<h1
className="dashboard-title"
style={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<BarChart3 size={24} style={{ color: 'var(--color-primary)' }} />
Revenue Progression & Reports
</h1>
<p className="dashboard-subtitle">
Query Juvonno data to calculate revenue progress, tax breakdowns, and growth trajectory.
</p>
</div>
<div className="reports-header-actions">
<button
type="button"
className="reports-btn reports-btn-ghost"
onClick={exportCsv}
disabled={!rawResponse || progression.length === 0}
title="Export progression table to CSV"
>
{copiedNotification ? <Check size={14} style={{ color: '#10b981' }} /> : <Download size={14} />}
{copiedNotification ? 'Exported CSV!' : 'Export CSV'}
</button>
</div>
</header>
{/* ── Error Banner ─────────────────────────────────────────────────────── */}
{error && (
<div className="reports-error-banner">
<div className="reports-error-content">
<AlertTriangle size={20} />
<div>
<strong>Webhook Connection Error</strong>
<div style={{ fontSize: '13px', marginTop: '2px' }}>{error}</div>
</div>
</div>
<button
type="button"
className="reports-btn reports-btn-primary"
onClick={() => runQuery({ sDate: startDate, eDate: endDate, stat: status, queryMode: mode })}
>
Retry
</button>
</div>
)}
{/* ── Controls: Presets Bar & Custom Range Toggle ───────────────────────── */}
<section className="reports-filter-card" aria-label="Report Controls">
<div className="reports-presets-row">
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontWeight: 600 }}>
QUICK PRESETS (MONTHLY):
</span>
<button
type="button"
className={`reports-preset-chip ${activePreset === 'ytd' ? 'active' : ''}`}
onClick={() => handlePresetClick('ytd')}
disabled={loading}
>
YTD
</button>
<button
type="button"
className={`reports-preset-chip ${activePreset === 'last-3-months' ? 'active' : ''}`}
onClick={() => handlePresetClick('last-3-months')}
disabled={loading}
>
Last 3 Months
</button>
<button
type="button"
className={`reports-preset-chip ${activePreset === 'last-month' ? 'active' : ''}`}
onClick={() => handlePresetClick('last-month')}
disabled={loading}
>
Last Month
</button>
<button
type="button"
className={`reports-preset-chip ${activePreset === 'all-year' ? 'active' : ''}`}
onClick={() => handlePresetClick('all-year')}
disabled={loading}
>
All {new Date().getFullYear()}
</button>
<button
type="button"
className={`reports-preset-chip custom-btn ${isCustomRangeOpen ? 'active' : ''}`}
onClick={toggleCustomRange}
title="Toggle custom date range inputs"
>
<SlidersHorizontal size={12} />
Custom Range
</button>
</div>
{/* ── Expandable Custom Range Panel (Daily Mode) ──────────────────────── */}
{isCustomRangeOpen && (
<form className="reports-custom-panel" onSubmit={handleCustomSubmit}>
<div className="reports-form-row">
<div className="reports-field-group">
<label className="reports-field-label">
<Calendar size={13} />
Start Date
</label>
<input
type="date"
className="reports-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="reports-field-group">
<label className="reports-field-label">
<Calendar size={13} />
End Date
</label>
<input
type="date"
className="reports-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
<div className="reports-field-group" style={{ maxWidth: '200px' }}>
<label className="reports-field-label">
<Filter size={13} />
Status
</label>
<select
className="reports-input"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="paid">Paid</option>
<option value="void">Void</option>
<option value="all">All Statuses</option>
</select>
</div>
<div>
<button
type="submit"
className="reports-btn reports-btn-primary"
disabled={loading}
style={{ padding: '9px 22px', fontSize: 'var(--font-size-sm)' }}
>
<RotateCw
size={14}
style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }}
/>
{loading ? 'Querying n8n…' : 'Run Custom Report'}
</button>
</div>
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>
* Custom date ranges aggregate and plot data at <strong>daily granularity</strong> for active invoice dates.
</div>
</form>
)}
</section>
{/* ── Initial Empty State ──────────────────────────────────────────────── */}
{!rawResponse && !loading && !error && (
<div className="reports-empty-card">
<div className="reports-empty-icon">
<BarChart3 size={28} />
</div>
<div className="reports-empty-title">Select a Query Range to Begin</div>
<p className="reports-empty-desc">
Choose a completed period preset above (e.g. <strong>Last Month</strong> or <strong>Last 3 Months</strong>) to view <strong>Monthly Revenue Progression</strong>, or open <strong>Custom Range</strong> to analyze specific dates with daily granularity.
</p>
</div>
)}
{/* ── Loading Placeholder ──────────────────────────────────────────────── */}
{loading && !rawResponse && (
<div className="reports-empty-card">
<div className="reports-empty-icon">
<RotateCw size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--color-primary)' }} />
</div>
<div className="reports-empty-title">Querying Juvonno via n8n…</div>
<p className="reports-empty-desc">Fetching invoice aggregates and calculating progression data.</p>
</div>
)}
{/* ── Active Report View ───────────────────────────────────────────────── */}
{rawResponse && (
<>
{/* ── KPI Summary Cards ──────────────────────────────────────────────── */}
<section className="reports-kpi-grid" aria-label="Key Performance Indicators">
<div className="reports-kpi-card cyan">
<div className="reports-kpi-top">
<span className="reports-kpi-title">Total Gross Revenue</span>
<div className="reports-kpi-icon">
<DollarSign size={16} />
</div>
</div>
<div className="reports-kpi-value">{formatCurrency(summary.totalGross)}</div>
<div className="reports-kpi-subtext">
<span>Overall billed volume</span>
</div>
</div>
<div className="reports-kpi-card emerald">
<div className="reports-kpi-top">
<span className="reports-kpi-title">Net Revenue (Pre-Tax)</span>
<div className="reports-kpi-icon emerald">
<TrendingUp size={16} />
</div>
</div>
<div className="reports-kpi-value">{formatCurrency(summary.totalNet)}</div>
<div className="reports-kpi-subtext">
<span>Core net earnings</span>
</div>
</div>
<div className="reports-kpi-card amber">
<div className="reports-kpi-top">
<span className="reports-kpi-title">Total Tax Collected</span>
<div className="reports-kpi-icon amber">
<Receipt size={16} />
</div>
</div>
<div className="reports-kpi-value">{formatCurrency(summary.totalTax)}</div>
<div className="reports-kpi-subtext">
<span>Sales tax remittance</span>
</div>
</div>
<div className="reports-kpi-card violet">
<div className="reports-kpi-top">
<span className="reports-kpi-title">
{mode === 'daily' ? 'Invoices & Daily Pace' : 'Invoices & Monthly Pace'}
</span>
<div className="reports-kpi-icon violet">
<BarChart3 size={16} />
</div>
</div>
<div className="reports-kpi-value">{formatNumber(summary.invoiceCount)}</div>
<div className="reports-kpi-subtext">
<span>
Across {summary.activePeriodsCount} {mode === 'daily' ? 'days' : 'months'} • ~
{formatCurrency(summary.avgPeriodGross)}/{mode === 'daily' ? 'day' : 'mo'}
</span>
</div>
</div>
</section>
{/* ── Main Chart Section ─────────────────────────────────────────────── */}
<section className="reports-chart-card" aria-label="Revenue Progression Chart">
<header className="reports-chart-header">
<div className="reports-chart-title-group">
<h2>{mode === 'daily' ? 'Daily Revenue Progression' : 'Monthly Revenue Progression'}</h2>
<p>
{mode === 'daily'
? 'Tracking daily invoice activity, net revenue, and cumulative progression trajectory'
: 'Tracking monthly gross, net without tax, and cumulative progression trajectory'}
</p>
</div>
<div className="reports-chart-toggles" role="tablist" aria-label="Chart view mode">
<button
type="button"
className={`reports-chart-toggle-btn ${chartView === 'combo' ? 'active' : ''}`}
onClick={() => setChartView('combo')}
>
Combo View
</button>
<button
type="button"
className={`reports-chart-toggle-btn ${chartView === 'period' ? 'active' : ''}`}
onClick={() => setChartView('period')}
>
{mode === 'daily' ? 'Daily Only' : 'Monthly Only'}
</button>
<button
type="button"
className={`reports-chart-toggle-btn ${chartView === 'cumulative' ? 'active' : ''}`}
onClick={() => setChartView('cumulative')}
>
Cumulative Only
</button>
</div>
</header>
<div className="reports-chart-canvas-wrapper">
{progression.length > 0 ? (
<Chart
type={chartView === 'cumulative' ? 'line' : 'bar'}
data={chartData}
options={chartOptions}
/>
) : (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: '12px',
color: 'var(--color-text-muted)',
}}
>
<BarChart3 size={36} />
<p>No active invoice records available for the selected range.</p>
</div>
)}
</div>
</section>
{/* ── Progression Table ──────────────────────────────────────────────── */}
<section className="reports-table-card" aria-label="Progression Table">
<header className="reports-table-header">
<div className="reports-table-title">
{mode === 'daily'
? 'Daily Breakdown & Cumulative Ramp'
: 'Monthly Breakdown & MoM Progression'}
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)' }}>
{progression.length} active {mode === 'daily' ? 'days' : 'months'}
</span>
</header>
<div className="reports-table-wrapper">
<table className="reports-table">
<thead>
<tr>
<th>{mode === 'daily' ? 'Date' : 'Month'}</th>
<th>Invoices</th>
<th>Net (Pre-Tax)</th>
<th>Tax</th>
<th>Gross Revenue</th>
<th>Cumulative Total</th>
<th>{mode === 'daily' ? 'DoD Change' : 'MoM Change'}</th>
</tr>
</thead>
<tbody>
{progression.map((row) => {
const isPositive = row.growthPercent > 0
const isNegative = row.growthPercent < 0
return (
<tr key={row.key}>
<td style={{ fontWeight: 600 }}>{row.label}</td>
<td>{row.count}</td>
<td>{formatCurrency(row.net)}</td>
<td style={{ color: 'var(--color-accent-amber)' }}>{formatCurrency(row.tax)}</td>
<td style={{ fontWeight: 600, color: 'var(--color-primary-hover)' }}>
{formatCurrency(row.gross)}
</td>
<td style={{ fontWeight: 600, color: '#a78bfa' }}>
{formatCurrency(row.cumulativeGross)}
</td>
<td>
{row.growthPercent !== null ? (
<span
className={`reports-mom-badge ${
isPositive ? 'positive' : isNegative ? 'negative' : 'neutral'
}`}
>
{isPositive ? (
<ArrowUpRight size={12} />
) : isNegative ? (
<ArrowDownRight size={12} />
) : null}
{formatPercentage(row.growthPercent)}
</span>
) : (
<span className="reports-mom-badge neutral">Base</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
</>
)}
</section>
)
}
File renamed without changes.
@@ -18,7 +18,7 @@ import {
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js' import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
import { fetchCustomerChart } from '../lib/juvonno.js' import { fetchCustomerChart } from '../lib/juvonno.js'
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js' import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
import CountrySelect from './CountrySelect.jsx' import CountrySelect from '../components/CountrySelect.jsx'
const STATUS_META = { const STATUS_META = {
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' }, 'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },