diff --git a/README.md b/README.md index 217bdab..9b6584c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/electron/main/index.js b/electron/main/index.js index 4b40c90..4a0f95b 100644 --- a/electron/main/index.js +++ b/electron/main/index.js @@ -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)}`) + } +}) + + diff --git a/electron/main/n8n-config.js b/electron/main/n8n-config.js new file mode 100644 index 0000000..4567383 --- /dev/null +++ b/electron/main/n8n-config.js @@ -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 \ No newline at end of file diff --git a/electron/preload/index.js b/electron/preload/index.js index cf22d97..be6f9ad 100644 --- a/electron/preload/index.js +++ b/electron/preload/index.js @@ -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 diff --git a/package-lock.json b/package-lock.json index 3f0484f..bd227de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f9590f7..4b22a30 100644 --- a/package.json +++ b/package.json @@ -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" }, @@ -58,4 +60,4 @@ "icon": "icons/icon.icns" } } -} \ No newline at end of file +} diff --git a/src/App.jsx b/src/App.jsx index 8dc1d44..f572519 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 case 'risk-audit': return case 'pre-travel': return + case 'messaging': return + case 'reports': return case 'notifications': return ( 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)}`) + } +} + diff --git a/src/components/Dashboard.jsx b/src/pages/Dashboard.jsx similarity index 100% rename from src/components/Dashboard.jsx rename to src/pages/Dashboard.jsx diff --git a/src/components/LiveTracking.jsx b/src/pages/LiveTracking.jsx similarity index 100% rename from src/components/LiveTracking.jsx rename to src/pages/LiveTracking.jsx diff --git a/src/pages/Messaging.jsx b/src/pages/Messaging.jsx new file mode 100644 index 0000000..129ca00 --- /dev/null +++ b/src/pages/Messaging.jsx @@ -0,0 +1,1052 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import { + MessageCircle, + Mail, + Search, + RotateCw, + Check, + AlertTriangle, + X, + Send, + ExternalLink, + Copy, + FileText, + Sparkles, + Calendar, + Phone, + Eye, + Edit3, + Settings2, + Syringe, + ShieldAlert, + Inbox, + UserCheck, + CheckCircle2, + HelpCircle, +} from 'lucide-react' +import { fetchCustomerChart } from '../lib/juvonno.js' + +// Default fallback n8n webhook URL (can be overridden via environment or UI settings) +const DEFAULT_WEBHOOK_URL = import.meta.env.VITE_N8N_MESSAGING_WEBHOOK_URL || '' + +// ── Preset Clinical Templates ────────────────────────────────────────────────── +const CLINICAL_TEMPLATES = [ + { + id: 'pre-travel-vaccine', + name: 'Pre-Travel Vaccine Schedule', + category: 'Immunization', + icon: Syringe, + subject: 'Pre-Travel Immunization Notice — CTM Clinic', + body: `Dear {patient_name}, + +Our clinical records indicate that you have upcoming international travel. + +Please be advised that certain destination-specific immunizations require multiple doses administered over specific intervals to achieve optimal protective immunity prior to your departure date. + +If you have not already scheduled your vaccination visit or booster appointment, please contact the CTM Clinic at your earliest convenience to ensure timely immunization. + +Best regards, +Travel Medicine Clinical Team +CTM Concierge Clinic +Tel: +1 (514) 555-0190 +Email: clinic@ctmconcierge.com`, + }, + { + id: 'travel-health-advisory', + name: 'Destination Health Advisory', + category: 'Risk Notice', + icon: ShieldAlert, + subject: 'Important Travel Health Advisory — CTM Clinic', + body: `Dear {patient_name}, + +We are contacting you with essential clinical guidelines regarding your upcoming travel itinerary. + +Key travel precautions for your destination: +• Mosquito-borne illness prevention (use approved DEET repellent & protective clothing) +• Food and potable water hygiene standards +• Malaria chemoprophylaxis adherence (if prescribed) +• Travel health insurance verification and emergency contact details + +Please review your travel medical plan. Feel free to contact our clinic desk if you experience any health changes before departure. + +Warm regards, +Clinical Services Team +CTM Concierge Clinic`, + }, + { + id: 'post-travel-followup', + name: 'Post-Travel Health Check-in', + category: 'Follow-Up', + icon: Calendar, + subject: 'Post-Travel Health Follow-Up — CTM Clinic & SnapMED', + body: `Dear {patient_name}, + +Welcome back! We hope your recent travels went safely and smoothly. + +This is a routine post-travel wellness check-in from CTM Clinic in collaboration with SnapMED. + +If you experience any concerning symptoms—such as fever, gastrointestinal illness, unusual fatigue, or skin lesions—within 4 to 8 weeks of returning, please contact us immediately or seek prompt medical evaluation. + +Wishing you continued health, +CTM Concierge Follow-Up Desk`, + }, + { + id: 'general-consultation', + name: 'General Consultation Note', + category: 'General', + icon: FileText, + subject: 'Medical Consultation Follow-Up — CTM Concierge', + body: `Dear {patient_name}, + +Following your recent clinical consultation at CTM Clinic, please find this written summary of your travel recommendations and prescription notes. + +Please let us know if you require any additional documentation, international vaccine certificates (ICVP), or prescription refills. + +Sincerely, +Dr. Medical Staff +CTM Concierge Clinic`, + }, +] + +// ── Merge Tags Available ─────────────────────────────────────────────────────── +const MERGE_TAGS = [ + { tag: '{patient_name}', label: 'Patient Name' }, + { tag: '{emr_id}', label: 'EMR ID' }, + { tag: '{date}', label: "Today's Date" }, + { tag: '{clinic_name}', label: 'Clinic Name' }, +] + +export default function Messaging() { + // ── Juvonno EMR Lookup State ──────────────────────────────────────────────── + const [emrIdInput, setEmrIdInput] = useState('') + const [emrSearching, setEmrSearching] = useState(false) + const [emrLookupError, setEmrLookupError] = useState(null) + const [juvonnoPatient, setJuvonnoPatient] = useState(null) + + // ── Form State ────────────────────────────────────────────────────────────── + const [form, setForm] = useState({ + to: '', + cc: '', + subject: '', + body: '', + }) + const [selectedTemplateId, setSelectedTemplateId] = useState(null) + const [showCcField, setShowCcField] = useState(false) + const [activeTab, setActiveTab] = useState('compose') // 'compose' | 'preview' + + // ── Webhook / Dispatch State ──────────────────────────────────────────────── + const [webhookUrl, setWebhookUrl] = useState(() => { + return localStorage.getItem('ctm_n8n_messaging_webhook') || DEFAULT_WEBHOOK_URL + }) + const [showWebhookModal, setShowWebhookModal] = useState(false) + const [webhookModalUrl, setWebhookModalUrl] = useState('') + const [sending, setSending] = useState(false) + const [dispatchStatus, setDispatchStatus] = useState(null) // { type: 'success'|'info'|'error', message: '', payload: any } + const [copiedPayload, setCopiedPayload] = useState(false) + + const bodyTextareaRef = useRef(null) + + // Auto-dismiss status banner after 6 seconds + useEffect(() => { + if (dispatchStatus && dispatchStatus.type !== 'error') { + const timer = setTimeout(() => setDispatchStatus(null), 6000) + return () => clearTimeout(timer) + } + }, [dispatchStatus]) + + // ── Tag Interpolation Helper ──────────────────────────────────────────────── + const interpolate = (text, patient) => { + if (!text) return '' + const name = patient?.fullName || 'Valued Patient' + const emr = patient?.id || patient?.emrId || '' + const todayStr = new Date().toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + + return text + .replace(/{patient_name}/g, name) + .replace(/{emr_id}/g, String(emr)) + .replace(/{date}/g, todayStr) + .replace(/{clinic_name}/g, 'CTM Concierge Clinic') + } + + // ── Juvonno EMR Lookup Handler ────────────────────────────────────────────── + async function handleLookupJuvonno() { + const rawId = Number(emrIdInput) + if (!rawId) { + setEmrLookupError('Please enter a valid numeric EMR ID.') + return + } + + setEmrSearching(true) + setEmrLookupError(null) + + try { + const data = await fetchCustomerChart(rawId) + const firstName = (data?.customer?.first_name || '').trim() + const lastName = (data?.customer?.last_name || '').trim() + const fullName = [firstName, lastName].filter(Boolean).join(' ') + const dob = data?.customer?.date_of_birth || '' + const email = + data?.customer?.email || + data?.customer?.email_address || + data?.customer?.email1 || + data?.email || + '' + const phone = + data?.customer?.cell_phone || + data?.customer?.home_phone || + data?.customer?.phone || + data?.customer?.work_phone || + '' + + const resolved = { + id: data.id || rawId, + firstName, + lastName, + fullName, + dob, + email, + phone, + } + + setJuvonnoPatient(resolved) + + // Auto-populate recipient email if retrieved + if (email) { + setForm((prev) => ({ + ...prev, + to: email, + })) + } + + // If a template was already loaded with placeholder {patient_name}, refresh body/subject + if (fullName) { + setForm((prev) => ({ + ...prev, + subject: interpolate(prev.subject, resolved), + body: interpolate(prev.body, resolved), + })) + } + } catch (err) { + setJuvonnoPatient(null) + setEmrLookupError(err.message || `Customer chart with EMR ID "${rawId}" not found in Juvonno.`) + } finally { + setEmrSearching(false) + } + } + + function handleResetJuvonno() { + setJuvonnoPatient(null) + setEmrLookupError(null) + setEmrIdInput('') + } + + // ── Template Selection ────────────────────────────────────────────────────── + function handleSelectTemplate(tpl) { + setSelectedTemplateId(tpl.id) + setForm((prev) => ({ + ...prev, + subject: interpolate(tpl.subject, juvonnoPatient), + body: interpolate(tpl.body, juvonnoPatient), + })) + } + + // ── Insert Merge Tag ──────────────────────────────────────────────────────── + function handleInsertTag(tag) { + if (!bodyTextareaRef.current) return + const textarea = bodyTextareaRef.current + const start = textarea.selectionStart || 0 + const end = textarea.selectionEnd || 0 + const currentVal = form.body + const newVal = currentVal.substring(0, start) + tag + currentVal.substring(end) + + setForm({ ...form, body: newVal }) + + // Restore cursor position after the inserted tag + setTimeout(() => { + textarea.focus() + textarea.setSelectionRange(start + tag.length, start + tag.length) + }, 0) + } + + // ── Clear / Reset Form ────────────────────────────────────────────────────── + function handleResetForm() { + if (form.body || form.subject || form.to) { + if (!window.confirm('Are you sure you want to clear the message composer?')) { + return + } + } + setForm({ to: '', cc: '', subject: '', body: '' }) + setSelectedTemplateId(null) + setDispatchStatus(null) + } + + // ── Construct Outbound Payload ────────────────────────────────────────────── + const outboundPayload = useMemo(() => { + return { + event: 'patient.message.dispatch', + timestamp: new Date().toISOString(), + patient: { + emrId: juvonnoPatient?.id || (Number(emrIdInput) || null), + name: juvonnoPatient?.fullName || '', + email: form.to.trim(), + dob: juvonnoPatient?.dob || null, + phone: juvonnoPatient?.phone || null, + }, + message: { + to: form.to.trim(), + cc: form.cc.trim(), + subject: form.subject.trim(), + body: form.body, + templateId: selectedTemplateId || 'custom', + characterCount: form.body.length, + }, + sender: { + app: 'CTM Concierge', + clinic: 'CTM Travel Medicine Clinic', + source: 'clinical-desk', + }, + } + }, [juvonnoPatient, emrIdInput, form, selectedTemplateId]) + + // ── Dispatch via n8n Webhook / Placeholder ────────────────────────────────── + async function handleSendEmail(e) { + if (e) e.preventDefault() + + if (!form.to.trim()) { + setDispatchStatus({ + type: 'error', + message: 'Recipient email address (To) is required.', + }) + return + } + + if (!form.subject.trim()) { + setDispatchStatus({ + type: 'error', + message: 'Email subject is required.', + }) + return + } + + if (!form.body.trim()) { + setDispatchStatus({ + type: 'error', + message: 'Email body cannot be empty.', + }) + return + } + + setSending(true) + setDispatchStatus(null) + + try { + if (webhookUrl && webhookUrl.trim().startsWith('http')) { + // Send actual HTTP POST request to configured n8n webhook + const response = await fetch(webhookUrl.trim(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(outboundPayload), + }) + + if (!response.ok) { + throw new Error(`n8n webhook responded with status ${response.status}: ${response.statusText}`) + } + + setDispatchStatus({ + type: 'success', + message: `Email successfully forwarded to n8n workflow for "${form.to}".`, + payload: outboundPayload, + }) + } else { + // Webhook placeholder simulation mode + await new Promise((r) => setTimeout(r, 600)) // subtle realistic delay + setDispatchStatus({ + type: 'info', + message: 'n8n Webhook Placeholder: Message validated & payload generated. (Configure a webhook URL to forward automatically).', + payload: outboundPayload, + }) + } + } catch (err) { + setDispatchStatus({ + type: 'error', + message: `Failed to dispatch message to n8n: ${err.message}`, + payload: outboundPayload, + }) + } finally { + setSending(false) + } + } + + // ── Open in Default Mail Client (mailto: fallback) ────────────────────────── + function handleOpenMailClient() { + if (!form.to.trim()) { + setDispatchStatus({ + type: 'error', + message: 'Please enter a recipient email before opening the email client.', + }) + return + } + + const mailtoUrl = `mailto:${encodeURIComponent(form.to.trim())}?subject=${encodeURIComponent( + form.subject + )}&body=${encodeURIComponent(form.body)}${form.cc ? `&cc=${encodeURIComponent(form.cc)}` : ''}` + + window.open(mailtoUrl, '_blank') + } + + // ── Copy Payload to Clipboard ─────────────────────────────────────────────── + async function handleCopyPayload() { + try { + await navigator.clipboard.writeText(JSON.stringify(outboundPayload, null, 2)) + setCopiedPayload(true) + setTimeout(() => setCopiedPayload(false), 2500) + } catch { + // Fallback + } + } + + return ( +
+ {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ + Communication & Outbox +
+

+ Patient Messaging +

+

+ Lookup patient chart details from Juvonno EMR, compose and send personalized health email notification. +

+
+ +
+ {/* n8n Webhook Status Pill / Config Trigger */} + + + +
+
+ + {/* ── Status Banner (Feedback / Webhook Payload) ────────────────────────── */} + {dispatchStatus && ( +
+
+ {dispatchStatus.type === 'success' && } + {dispatchStatus.type === 'error' && } + {dispatchStatus.type === 'info' && } +
+ {dispatchStatus.type === 'success' ? 'Dispatched' : dispatchStatus.type === 'error' ? 'Dispatch Error' : 'Ready'} +

{dispatchStatus.message}

+
+
+ +
+ {dispatchStatus.payload && ( + + )} + +
+
+ )} + + {/* ── Main Two-Column Layout ────────────────────────────────────────────── */} +
+ {/* ── Left Column: Patient Lookup & Templates ────────────────────────── */} + + + {/* ── Right Column: Email Composer & Live Preview ────────────────────── */} +
+
+ {/* Composer Card Header with Tabs */} +
+
+ + Email Dispatcher +
+ + {/* View Switcher: Compose vs Preview */} +
+ + +
+
+ + {/* TAB 1: COMPOSE VIEW */} + {activeTab === 'compose' ? ( +
+ {/* Field: To (Recipient) */} +
+
+ + {!showCcField && ( + + )} +
+
+ setForm({ ...form, to: e.target.value })} + required + /> +
+
+ + {/* Optional Field: CC */} + {showCcField && ( +
+
+ + +
+ setForm({ ...form, cc: e.target.value })} + /> +
+ )} + + {/* Field: Subject */} +
+ + setForm({ ...form, subject: e.target.value })} + required + /> +
+ + {/* Field: Body Textarea */} +
+
+ + {form.body.length} characters +
+ + {/* Merge Tag Insertion Chips */} +
+ Quick Merge Tags: +
+ {MERGE_TAGS.map(({ tag, label }) => ( + + ))} +
+
+ +