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
|
||||
├── src/
|
||||
│ ├── 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
|
||||
│ │ ├── 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
|
||||
│ ├── index.css # CSS styles, layout grid, CSS custom properties (variables)
|
||||
│ └── main.jsx # React application entry point
|
||||
@@ -49,14 +57,14 @@ graph TD
|
||||
## Core Features
|
||||
|
||||
### 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`).
|
||||
- **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.
|
||||
- **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
|
||||
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.
|
||||
- **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,
|
||||
} from './pocketbase.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))
|
||||
|
||||
@@ -161,9 +167,6 @@ ipcMain.handle('pb:archive-notification', async (_event, id, isArchived) => {
|
||||
|
||||
// ─── Health Risk Audit IPC Handler (Node-side to avoid CORS) ────────────────
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -208,5 +211,29 @@ ipcMain.handle('juvonno:fetch-chart', async (_event, emrId) => {
|
||||
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',
|
||||
'audit:run-health-audit',
|
||||
'juvonno:fetch-chart',
|
||||
'reports:fetch-report',
|
||||
]
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
@@ -56,6 +57,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'pb:archive-notification',
|
||||
'audit:run-health-audit',
|
||||
'juvonno:fetch-chart',
|
||||
'reports:fetch-report',
|
||||
]
|
||||
|
||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||
@@ -95,6 +97,13 @@ contextBridge.exposeInMainWorld('api', {
|
||||
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.
|
||||
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
|
||||
|
||||
Generated
+32
-2
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "ctm-concierge",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ctm-concierge",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-react": "^1.3.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"pocketbase": "^0.27.0",
|
||||
"react": "^18.3.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-map-gl": "^8.1.1"
|
||||
},
|
||||
@@ -1244,6 +1246,12 @@
|
||||
"@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": {
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
@@ -6124,6 +6144,16 @@
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-react": "^1.3.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"pocketbase": "^0.27.0",
|
||||
"react": "^18.3.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-map-gl": "^8.1.1"
|
||||
},
|
||||
|
||||
+11
-7
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Sidebar from './components/Sidebar.jsx'
|
||||
import Dashboard from './components/Dashboard.jsx'
|
||||
import LiveTracking from './components/LiveTracking.jsx'
|
||||
import Trips from './components/Trips.jsx'
|
||||
import Records from './components/Records.jsx'
|
||||
import RiskAudit from './components/RiskAudit.jsx'
|
||||
import PreTravel from './components/PreTravel.jsx'
|
||||
import Notifications from './components/Notifications.jsx'
|
||||
import Dashboard from './pages/Dashboard.jsx'
|
||||
import LiveTracking from './pages/LiveTracking.jsx'
|
||||
import Trips from './pages/Trips.jsx'
|
||||
import PreTravel from './pages/PreTravel.jsx'
|
||||
import Records from './pages/Records.jsx'
|
||||
import RiskAudit from './pages/RiskAudit.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'
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,8 @@ export default function App() {
|
||||
case 'records': return <Records />
|
||||
case 'risk-audit': return <RiskAudit />
|
||||
case 'pre-travel': return <PreTravel />
|
||||
case 'messaging': return <Messaging />
|
||||
case 'reports': return <Reports />
|
||||
case 'notifications':
|
||||
return (
|
||||
<Notifications
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Settings,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
ShieldAlert
|
||||
ShieldAlert,
|
||||
MessageCircle
|
||||
} from 'lucide-react'
|
||||
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
|
||||
import { fetchEntries } from '../lib/pocketbase.js'
|
||||
@@ -23,7 +24,8 @@ const NAV_ITEMS = [
|
||||
|
||||
const TOOLS_ITEMS = [
|
||||
{ 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 = [
|
||||
|
||||
+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 { fetchCustomerChart } from '../lib/juvonno.js'
|
||||
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
|
||||
import CountrySelect from './CountrySelect.jsx'
|
||||
import CountrySelect from '../components/CountrySelect.jsx'
|
||||
|
||||
const STATUS_META = {
|
||||
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
|
||||
Reference in new issue
Block a user