KNOWLEDGE BASE // PROFITABILITY CONSOLE
v3.1 · 2026
KNOWLEDGE BASE // PROFITABILITY CONSOLE
Profitability & Margin Analysis
A CEO-level dashboard for per-client gross margin visibility, EHR monitoring, upsell flag generation, and margin erosion alerting across the full MSP client portfolio. Integrates with ConnectWise and QuickBooks.
12 Demo Clients
Chart.js · 5 Insight Charts
Live Simulation · 5s Refresh
PSA + Billing API Integration
01 Overview

The Profitability Console is a single-page HTML dashboard built for MSP leadership and account managers who need to answer one core question at a glance: which clients are making money and which ones are eroding it?

It renders per-client gross margins, effective hourly rates, cost breakdowns, upsell opportunities, and trend data from a unified data layer. In demo mode, all data is generated client-side and auto-simulated every 5 seconds. In production, data flows from ConnectWise PSA (ticket volume, labor hours, cost categories) and QuickBooks (vendor costs, billing actuals).

External Dependency
This dashboard requires Chart.js 4.4.1 loaded from the Cloudflare CDN. Unlike the OpsCore dashboard which uses Canvas API directly, this file depends on Chart.js for all five insight charts and all three deep-dive charts. If the CDN is unreachable, charts will not render — the KPI strip and client cards will still function.
Primary Audience

MSP owners, CEOs, vCIOs, and account managers conducting monthly business reviews, QBRs, pricing audits, or identifying clients at risk of churn due to service delivery cost overruns.

Key Decisions It Supports

Which clients need a price increase conversation. Which ones have upsell gaps worth pursuing. Which service lines are dragging portfolio margin. Where labor hours are misaligned to contract value.

02 Dashboard Sections

The dashboard is a single scrolling page with four logical zones navigated via the sticky topbar and the right-side dot navigation. There are no tabs — all content is always rendered.

ZoneIDPurpose
Hero + KPI Strip #hero Title, description, feature chips. KPI strip renders below the hero inside its own padded wrapper. Always visible at load.
Filter Bar .filter-bar Sticky bar below the topbar with Period, Margin tier, Service type dropdowns and a client search. Drives the client grid in real time.
Client Portfolio #s-portfolio Responsive card grid of all clients. Each card is clickable and expands a deep-dive panel inline below the grid.
Aggregate Insights #s-insights Five Chart.js charts covering service contribution margins, 90-day margin forecast, client distribution, 12-month MRR vs cost trend, and EHR by client.
Erosion Alerts #s-alerts Static alert feed of 8 pre-defined alerts with high / medium / low severity, client name, description, and detection source metadata.
Navigation Note
The right-side dot navigation (#sidenav) activates via scroll position using offsetTop comparisons. The topbar links scroll to named section IDs. Both are driven by the same navTo(id) function and updateSideNav() scroll listener. The dots are hidden on viewports below 900px.
03 KPI Strip

The KPI strip is a horizontal row of seven tiles rendered by renderKPIs(). It re-renders every 5 seconds as part of the live simulation. Each tile has a color-coded top accent bar, a headline value, a delta indicator, and a mini sparkline generated from random bar heights.

KPICalculationColorDelta Label
Total MRRSum of client.mrr across all clientsblueHardcoded +3.2% MoM
Overall Gross MarginAverage of client.margingreenHardcoded +1.1% MoM
Net Profit MTDSum of (mrr - costs) across all clientsgreenHardcoded +8.4% YoY
Avg EHRAverage of client.ehryellowHardcoded -2.1% MoM
Upsell PipelineTotal upsell flag count across all clientsblueHardcoded $42K potential
Erosion AlertsCount of alertsData items with sev === 'high'redTotal alert count
Forecast 90 DaysavgMargin + 2greenHardcoded +2% projected
Production Note
The delta labels and the 90-day forecast value are all hardcoded strings in the current version. Replace these with computed values pulled from your PSA's period-over-period API responses before using this in a client-facing or board-level context. The sparklines are cosmetic — random bar heights generated fresh each render.
04 Client Cards & Status Tiers

Each client is rendered as a card in a responsive auto-fill grid with a minimum column width of 300px. Cards are color-coded by margin tier, show six metrics at a glance, include a margin progress bar, and surface the first upsell flag if one exists.

Margin Tier System

HEALTHY
Margin > 40%
Green top bar. Standard monitoring cadence.
WATCH
Margin 20–40%
Yellow top bar. Review cost structure quarterly.
CRITICAL
Margin < 20%
Red top bar. Immediate pricing or scope review needed.

Card Metrics

FieldSourceColor Logic
MRRclient.mrrAlways cyan — revenue is neutral
Costsclient.costsAlways muted — informational
Gross Marginclient.margin + client.deltaGreen / yellow / red matching tier. Delta colored green if positive, red if negative.
Net Profitmrr - costsAlways green — assumes positive; no negative state in demo
EHRclient.ehrGreen ≥ $160/hr, yellow ≥ $130/hr, red below $130/hr
Tierclient.tierMuted label — enterprise / mid-market / smb

Margin Bar

The thin bar at the bottom of each card is a visual representation of client.margin as a percentage width. It uses the same green/yellow/red color as the margin tier. At 100% margin the bar would be full-width; at 20% it is narrow. This gives an instant visual density read across the grid without reading numbers.

Upsell Flag Preview

If a client has one or more upsell opportunities, a yellow banner shows the count and the first flag title truncated at the dash character. If none exist, a muted "No upsell flags" line appears instead. Clicking the card opens the full upsell list in the Deep Dive panel.

05 Deep Dive Panel

Clicking any client card triggers toggleDeepDive(id), which renders a full-width panel directly below the client grid (#deepDive). Clicking the same card again, or the × button, calls hideDeepDive(). Only one deep dive can be open at a time — opening a second one closes the first.

Chart Lifecycle
Deep dive charts are created inside a setTimeout(..., 50ms) delay to allow the panel to become visible in the DOM before Chart.js reads canvas dimensions. When the panel is closed, all three chart instances are explicitly destroyed via chartInstances[id].destroy() to prevent memory leaks and canvas reuse conflicts.

Deep Dive Components

ComponentChart TypeWhat It Shows
EHR Trend · 6 Months Line (Chart.js) Simulated 6-month EHR history ending at client.ehr, with a dashed green target line at $160/hr. Includes current EHR, target range, and delta vs. target in a stat list below.
Service Contribution Doughnut (Chart.js) Percentage breakdown of revenue by service line using client.breakdown (security / backup / support / other or hardware). Legend positioned to the right.
Cost vs Revenue · Monthly Bar waterfall (Chart.js) Decomposition of MRR into labor (55% of costs), vendor (30% of costs), overhead (remainder), and net profit. Negative bars shown in red and orange. Stat list shows MRR, Costs, Net Profit, Gross Margin.
Upsell Opportunities HTML list (no chart) All upsell flags for the client with title and estimated MRR uplift detail. If none exist, a "well-covered" placeholder is shown.
Waterfall Chart Note
The cost waterfall is not a true waterfall chart — it is a standard bar chart with negative values for cost bars. This means bars for labor, vendor, and overhead extend downward from zero rather than stacking from a MRR baseline. This is a cosmetic simplification. A true waterfall would require floating bar configuration in Chart.js using [start, end] data arrays.
06 Aggregate Insights Charts

The Insights section renders five Chart.js charts in a 2-column grid via renderInsightCharts(), called once on page load. These charts use portfolio-level demo data — they do not re-render on the 5-second simulation tick or respond to the filter bar.

ChartIDTypeData Source
Service Contribution Margins #chartService Grouped bar Hardcoded: Revenue % and Margin % across 6 service lines. Replace with a live query aggregating PSA billing line items by category.
Margin Forecast · Next 90 Days #chartForecast Dual line Hardcoded actuals Apr–Sep, projected Oct–Dec. The gap/bridge between the two series is intentional — spanGaps: false creates the visual break. Replace projected series with a regression or pipeline model.
Client Margin Distribution #chartDist Bar histogram Computed live from clients array using 7 margin buckets (<20 through 70+). Colors shift red → yellow → cyan → green across buckets. This is the one insight chart that reflects the live client data.
MRR vs Cost Trend · 12 Months #chartMRR Dual line with fill Random walk starting at $265K MRR baseline with costs at ~44% of MRR. Replace with 12-month actuals from QuickBooks. The visual gap between the two lines is gross profit.
EHR by Client #chartEHR Bar + line overlay All clients sorted descending by EHR. Bars colored green ≥ $160, yellow ≥ $130, red below. Dashed target line at $160/hr. This chart is always sorted at render time and does not update on simulation ticks.

Chart Configuration

All Chart.js instances share three helper functions that enforce the design system:

chartDefaults()Sets responsive: true, disables aspect ratio lock, sets animation to 600ms, and applies the dark tooltip style (dark background, JetBrains Mono, muted text colors).
yAxis()Grid lines at 4% opacity, border at 6% opacity, ticks in var(--dim) at 8px JetBrains Mono. Merged via spread into scales.y.
xAxis()Same as yAxis but with 3% grid opacity. Applied to scales.x.

All chart instances are stored in the chartInstances object keyed by canvas ID. Deep-dive charts use this same store and are destroyed on panel close. The portfolio-level insight charts are never destroyed — they persist for the page lifetime.

07 Margin Erosion Alerts

The Erosion Alerts section is a static feed rendered once by renderAlerts() at page load. The alertsData array is hardcoded — alerts do not update during the live simulation. In production, replace the array with API calls to your PSA's cost variance reports and billing anomaly endpoints.

SeverityVisualIntended Use
HIGHRed badge + red border on hover + 🔴 iconImmediate financial risk — margin at or below critical threshold, active cost spike, labor ratio collapse
MEDIUMYellow badge + 🟡 iconTrend-based warnings — 3-month declining deltas, overrun storage tiers, misaligned engineer hours
LOWCyan badge + 🔵 iconOpportunity alerts — seat utilization approaching cap, supplier cost increases not re-quoted, upsell windows

Alert Data Shape

alertsData item shape // JavaScript
{
  client: 'Zeta Health',
  msg:    'Support hours +38% MoM — EHR dropped from $142 to $98.',
  sev:    'high',   // 'high' | 'med' | 'low'
  meta:   'Detected via PSA ticket volume · 2h ago'
}

The meta field is intended to communicate where the alert came from — PSA ticket analysis, QuickBooks cost sync, vendor billing API, or trend analysis. In production this field should be populated programmatically with the detection source and timestamp.

08 Filters

The filter bar sits between the KPI strip and the Client Portfolio section. It controls the renderClientGrid() output in real time. All four controls share a single getFiltered() function that applies each condition sequentially.

ControlIDTypeFilter Logic
Period#dateFilterSelectCosmetic only in demo mode. Options: MTD, QTD, YTD. Wire to a date-range query parameter in production to re-fetch data for the selected window.
Margin#marginFilterSelectFilters by client.color: green = margin > 40%, yellow = 20–40%, red = < 20%. The all option shows all clients.
Service#serviceFilterSelectFilters by exact match on client.service: security, backup, breakfix, hardware.
Client Search#clientSearchText inputCase-insensitive substring match on client.name.toLowerCase(). Filters on every input event — no submit required.
Filter Wiring
The Period, Margin, and Service selects are bound with addEventListener('change', applyFilters). The search input uses addEventListener('input', applyFilters). If you add new filter controls, follow this same pattern — getFiltered() is the single function to modify, and applyFilters() just calls renderClientGrid(getFiltered()).

The "Showing N clients" count is updated inside renderClientGrid() by setting document.getElementById('clientCount').textContent. This count reflects the filtered list, not the total client count.

09 Data Model

All client data lives in a single const clients array at the top of the script block. Each object represents one managed client and carries all fields needed for every component — the grid, filters, deep dive, and aggregate charts.

Full client object shape // JavaScript
{
  id:        1,
  name:      'Acme Corp',
  mrr:       8500,       // Monthly recurring revenue ($)
  costs:     3400,       // Monthly delivery costs ($)
  margin:    60,         // Gross margin % — (mrr-costs)/mrr * 100
  delta:     2,          // Margin change MoM (%). Mutated by simulation.
  ehr:       185,        // Effective hourly rate ($/hr). Mutated by simulation.
  tier:      'enterprise',// 'enterprise' | 'mid-market' | 'smb'
  service:   'security', // Primary service: 'security'|'backup'|'breakfix'|'hardware'
  color:     'green',    // Margin tier: 'green'|'yellow'|'red'

  upsells: [            // Array of upsell opportunity objects (can be empty)
    {
      title:  'EDR Gap — 42 unprotected endpoints',
      detail: 'Potential MRR uplift: +$4,200/mo · CrowdStrike Falcon Go'
    }
  ],

  breakdown: {           // Service contribution % for doughnut chart (must sum to 100)
    security: 62,
    backup:   18,
    support:  12,
    other:    8          // OR 'hardware' for hardware-primary clients
  }
}
Margin Consistency
The margin field and the color field must be consistent. The color is not computed from the margin — it is a separate field. If you update a client's margin via live data and the color does not update to match, the card will show the wrong tier color. In production, derive color programmatically: margin > 40 ? 'green' : margin >= 20 ? 'yellow' : 'red'.

Alert Data

Alerts live in a separate const alertsData array. They reference clients by name string (not by ID) — this is a loose coupling that is fine for demo purposes but should be tightened to use client ID in production to avoid name-change mismatches.

10 Integrations

The dashboard is wired for two primary integrations. The topbar labels them: PSA + Billing API. In the demo, both are replaced by the static clients and alertsData arrays.

ConnectWise PSA
PSA

Provides ticket volume, time entries, labor hours per client, and ticket type breakdown (managed vs. break-fix). Used to calculate EHR and labor cost contribution.

Key endpoints: GET /service/tickets, GET /time/entries, GET /service/tickets/count

QuickBooks
BILLING

Provides vendor costs, license spend, and billing actuals per client. Used to compute true costs and validate MRR figures against invoiced amounts.

Key endpoints: QuickBooks Online REST API — GET /query?query=SELECT * FROM Invoice WHERE CustomerRef

Replacing Demo Data with Live Feeds

  1. 01
    Fetch CW time entries per client. Query /time/entries?conditions=dateEntered>=[period_start], group by company, sum hours, multiply by internal rate to get labor cost. This replaces the hardcoded costs field (currently costs = ~44% of MRR).
  2. 02
    Pull MRR from QuickBooks invoices. Query all recurring invoices in the billing period, group by customer reference. This replaces the hardcoded mrr field.
  3. 03
    Compute margin server-side. Calculate margin = Math.round((mrr - costs) / mrr * 100) and assign color programmatically. Never trust a hardcoded color field in production.
  4. 04
    Derive EHR from CW time entries. EHR = mrr / totalHoursThisPeriod. If using a monthly period, normalize to hours worked per month not hours billed.
  5. 05
    Generate upsell flags from coverage gaps. Query CW configuration items and asset inventory to identify clients missing EDR, SIEM, backup tiers, or M365 licensing. Construct the upsell objects programmatically and inject into each client record.
  6. 06
    Generate alerts from anomaly detection. Compare current-period EHR, margin, and ticket type ratios against trailing averages. Flag where delta exceeds defined thresholds and build the alertsData array dynamically.
11 Live Simulation

The dashboard simulates live data movement using a setInterval that fires every 5,000ms. This is purely cosmetic for demo environments — it shows stakeholders that the dashboard would feel alive with real API data.

Live simulation loop // JavaScript
setInterval(() => {
  clients.forEach(c => {
    // Random walk delta: ±1 with 60% probability of moving
    c.delta = Math.max(-15, Math.min(10,
      c.delta + (Math.random() > .6 ? 1 : -1)
    ));
    // Random walk EHR: ±1, clamped $80–$230
    c.ehr = Math.max(80, Math.min(230,
      c.ehr + (Math.random() > .5 ? 1 : -1)
    ));
  });
  renderKPIs();    // Re-renders KPI strip + sparklines
  applyFilters(); // Re-renders client grid with current filters
}, 5000);
Production — Disable or Replace
The simulation mutates client.delta and client.ehr directly on the data objects. In production, replace this interval with a polling function that re-fetches live API data and rebuilds the clients array. Do not ship the simulation interval in a production deployment — it will overwrite real data with noise on every tick.

Note that applyFilters() calls renderClientGrid(getFiltered()) — so the simulation respects whatever filters are currently active. The insight charts and alert feed are not re-rendered by the simulation tick.

12 Deployment

Go-Live Checklist

  1. 01
    Remove the live simulation interval. Delete the entire setInterval() block at the bottom of the script. Replace with a refreshData() function that re-fetches from your APIs on a sensible polling cadence (5–15 minutes for financial data).
  2. 02
    Replace the clients array with an API fetch. Build a data pipeline that queries CW and QuickBooks, normalizes the results into the client object shape documented in Section 09, and populates the array on load.
  3. 03
    Derive margin and color programmatically. Never hardcode the margin or color fields. Compute them from live MRR and costs at fetch time.
  4. 04
    Replace hardcoded KPI deltas. The +3.2% MoM and similar strings in renderKPIs() need to be computed from period-over-period comparisons. At minimum, pull the previous period's total MRR and compute the real delta.
  5. 05
    Address the Chart.js CDN dependency. If this dashboard will be served in an environment without reliable internet access, host Chart.js locally or bundle it. The file currently loads from cdnjs.cloudflare.com — if that request fails, the entire Insights section and all deep-dive charts will be blank.
  6. 06
    Update nav.js path. The file references <script src="../nav.js" defer></script>. Ensure this resolves correctly relative to where the file is deployed in your folder structure, or replace with an absolute path.
  7. 07
    Verify Chart.js canvas sizing at target resolutions. The insight charts use height: 200px or height: 240px on their wrapper divs. Test at your target screen resolution — the full-width EHR bar chart may need a taller wrapper at high client counts.
  8. 08
    Review mobile breakpoint. Below 900px, the grid collapses to single column, the side navigation dots are hidden, and section padding reduces. Test on the devices your team uses for in-meeting presentations.
13 Extending the Dashboard

Adding a New Client

Push a new object to the clients array matching the full shape in Section 09. The client card grid, all filters, the margin distribution chart, and the EHR by client chart will all pick it up automatically on next render. No HTML changes required.

Adding a New KPI Tile

Add a new object to the kpis array inside renderKPIs(). Each tile takes four fields: label, value, delta, and cls (one of c-green, c-yellow, c-red, c-blue). The flexbox strip will wrap automatically — for clean grid alignment, keep the total count a multiple of 3 or 4.

Adding a New Insight Chart

  1. 01
    Add an .insight-card div inside #s-insights .insights-grid with a canvas element and a unique ID. Use .insight-card.full for a full-width chart.
  2. 02
    Add the Chart.js instantiation inside renderInsightCharts(), storing the instance as chartInstances['yourChartId'].
  3. 03
    Use ...chartDefaults() spread, ...yAxis(), and ...xAxis() for design system consistency.

Adding a New Upsell Flag Type

Add a new object to a client's upsells array. The deep dive panel renders all upsells for the selected client — there is no maximum count, though more than 4–5 will require scrolling in the panel. The card preview only ever shows the first flag's title (truncated at the dash).

Adding a New Service Type to Filters

Add a new <option value="myservice"> to the #serviceFilter select. Set the matching client.service string on the relevant client objects. No JavaScript changes needed — getFiltered() does an exact string match against the select value.

14 FAQ
Q The Insights charts are blank.

Almost always a CDN failure. Open the browser console — if you see a Chart.js 404 or network error, the CDN request failed. Self-host Chart.js by downloading chart.umd.min.js from the Chart.js GitHub releases, placing it in your project, and updating the <script src> tag to a local path.

Q Opening a second deep dive doesn't close the first — I see two open at once.

This should not happen — toggleDeepDive(id) calls showDeepDive() which replaces the entire innerHTML of #deepDive regardless of previous state. If you are seeing two open panels, check whether there are two #deepDive elements in the DOM (duplicate HTML). There should only be one.

Q The margin color on a card does not match the margin percentage shown.

The color field is independent of the margin field in the data model. In the demo they are kept in sync manually. In production, always derive color from margin at data-fetch time: color: margin > 40 ? 'green' : margin >= 20 ? 'yellow' : 'red'. Never trust a hardcoded color field once live data is flowing.

Q The Period filter (MTD / QTD / YTD) doesn't change anything.

Correct — the Period dropdown is cosmetic in the current version. It is wired to the applyFilters() change listener but getFiltered() does not use its value. To make it functional, add a const period = document.getElementById('dateFilter').value check inside getFiltered() and use it to filter by client.dateEntered once you have date-stamped client records from your API.

Q How do I stop the numbers changing every 5 seconds?

Remove the setInterval() block at the bottom of the script. This stops both the delta/EHR mutation and the periodic re-render of the KPI strip and client grid. The page will then show static data until you implement a real refresh mechanism.

Q The service contribution breakdown in the deep dive doesn't add up to 100% for some clients.

One client (Iota Retail) uses hardware: 40 instead of other: 40 in the breakdown object. The deep dive doughnut chart reads bd.other || bd.hardware || 0 to handle both cases. If you add a fifth service line to a client's breakdown, you must also update the renderDDChartPie() labels array and the dataset data array to include the fifth slice.

Q Can I add more than 12 clients?

Yes — push any number of client objects to the clients array. The card grid uses auto-fill with a 300px minimum, so it scales to any count. Performance should be fine up to ~100 clients in the browser. The EHR by client bar chart will become crowded above ~20 clients — consider adding a top-N slice or making the chart wrapper taller using .ic-chart-wrap.tall for that canvas.