Added Messaging and Reports pages; connected Reports to n8n.
This commit is contained in:
1 parent
697d72f379
commit
cb3325d6df
19 files changed
+3792
-20
No files matched your search
@@ -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
@@ -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)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+32
-2
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-7
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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.
@@ -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' },
|
||||||
Reference in new issue
Block a user