Report Center & Dashboards
Where the ~75 pre-built reports live, how a report actually runs, and how the curated dashboards differ from the ones you build yourself.
On this page
Where it lives
The CRM admin sidebar has a Reports link that points to /crm/admin/reports. That route renders a single client component, ReportsHub, which is a five-tab shell driven by a ?tab= query param — Dashboard, Custom Dashboards, Report Center, My Reports, and Graphics Library. This is the "Report Center" proper. It's a separate thing from the top-level /dashboards route group covered below — that one is reached from its own sidebar (ReportsSidebar) and holds a set of hand-built, curated dashboards, one of which ("Reports Dashboard") happens to be Landscapt's older built-in reporting page (CRMReports component).
The Report Center tab
The Report Center tab renders ReportCatalog — a single searchable table, grouped by section header rows, listing every report in ALL_REPORTS. That array is the union of twelve definition arrays, one array per ReportSectionKey. Counting the actual section: entries across those files gives 79 reports total, close to the ~70 figure — spread across twelve sections:
| Section | Count | Examples actually found in the code |
|---|---|---|
| Service Reports | 14 | Visits Report, Backlog Services, Client Count by Service, Client Services Report, Package Summary Report, Skipped Visits Report, Daily Load List |
| Client | 11 | Client Balance, Client Contact List, Client Referral, New Clients Report, Terminations Report, Cancellation Count Report |
| Revenue | 10 | Invoice Audit Summary, Payment Audit Summary, Revenue by Postal Code, Revenue by Service Summary, Daily Production, Sales Activity Summary |
| Schedule Lists | 8 | Employee Directory, Vendor Contact List, Inventory Product List, Call Ahead Required, Service Price List |
| Job Costing | 8 | Job Costing Report, Cost of Goods Sold Report, Job Cost Summary, Service Profitability Summary, Production Rate Accuracy, WIP Report |
| Financial | 6 | Invoiced Income by Client, Invoices with Balances, Pre-Payments, Profit / Loss (Accrual), Profit / Loss (Cash), Sales Tax Report |
| Audits | 9 | Client Timeline Report, Lead Timeline Report, Income Not Invoiced, Visits — Client Has Balance Due, Unapplied Payments, Sales Commission Export, Audit Log, Security & Access Audit, Change Activity by User |
| Lead | 5 | New Leads Report, Lead Aging Summary, Closed Leads Summary, Company Scorecard, Sales Summary by Source |
| Estimates | 4 | Estimates by Stage, Accepted Estimates by Service, Accepted Estimates — Estimated vs Invoiced Value |
| Job Hours | 3 | Job Hours Summary, Crew Hours Summary, Timesheet Detail |
| Receivables | 2 | A/R Aging Report, A/R Aging Snapshot |
| Forms | 1 | Forms Summary |
Clicking a report name in the catalog links to /crm/admin/reports/r/[reportKey], rendered by ReportViewer.
How a report actually runs
Every catalog entry is a PrebuiltReportDef, and its doc comment spells out the three shapes a report can take — exactly one of:
- Declarative (
analysis) — the vast majority. The definition'sanalysis(params)function builds anAnalysisConfig(dataset name, columns, filters, group-by, aggregates, sort) from the filter-bar values, and that config is executed through thecrm_run_reportPostgres RPC viarunAnalysis(). The RPC itself re-validates every identifier server-side; the client-sidevalidateAnalysisConfigonly exists to fail fast with a friendlier error message. - Bespoke handler (
run) — for shapes the generic group-by/aggregate engine can't produce: aging buckets, month-column matrices, multi-table summaries. These get a hand-written async function that queries directly and returns aReportResult. - Link-out (
href) — a handful of reports (e.g. the Job Costing Report and COGS Report) are really pointers to an existing standalone page under/crm/reports/*— the catalog entry exists so they're searchable and appear in the same list, butReportViewerjust redirects (LinkOutCard). Job Costing and COGS share one loader (src/lib/visit-costing.ts) whose unit of analysis is a completed visit in the date window: revenue is the visit's service rate, labor is the crew's clock-out labor cost or — when nobody clocked out — an estimate of man-hours × the crew's labor burden rate (marked with a dagger, †), materials are the job materials logged against the visit, and a multi-service visit is split across its services by man-hour share.
The actual HTTP execution path for declarative and bespoke reports is a GET to /api/crm/reports/run/[reportKey]: it authenticates the user, looks up the definition by key in REPORT_MAP, flattens the URL query string into filter params, and calls either def.run() or runAnalysis(def.analysis(params)) — the client hook driving this is useRunReport.
Every declarative report reads from one of 21 named datasets defined in REPORT_DATASETS, each a flat, pre-joined view over the underlying tables (columns, types, and — for enum-like text columns — a fixed option list). The full set:
Clients · Client Contacts · Client Timeline · Jobs · Job Visits · Job Services (Production Rate Accuracy) · Invoices · Invoice Line Items · Payments · Estimates · Estimate Line Items · Contracts · Timesheets · Employees · Services · Vendors · Products · Chemical Applications · Projects — WIP Schedule · Sales Rep — Current Month · Contract Service Usage
Worked example: Production Rate Accuracy
Production Rate Accuracy lives in the Job Costing section. Its stated purpose: "Compares each service's assumed production rate (sq ft per man-hour) against what crews actually achieved, to flag rates that need recalibrating."
- Open it from the Report Center catalog or navigate directly to
/crm/admin/reports/r/production-rate-accuracy. - It has a single filter — Scheduled Between, a date-range picker defaulting to "This Month" (
dateRangeFilterDef("Scheduled Between", "this_month")). Changing the preset (This Month, Last Month, Last 30/90 Days, This Year, All Time, or Custom) recomputes thefrom/towindow client-side (computePresetRange) and re-runs the query. All Time sends an explicitly unbounded range — it really means every row, not a silent fallback to This Month. - Under the hood, its
analysis()builds a query against therpt_job_servicesdataset, filtered tobudget_method = "production_rate"andvisit_status = "completed"— the visit's status, so a recurring job's not-yet-done visits stay out even once the job itself is marked complete — plus the date-range filter onscheduled_date. It pulls columns like scheduled date, client, service name, quantity/unit, assumed vs. actual production rate, rate variance (in basis points), budgeted vs. actual hours, and crew size — sorted ascending byrate_variance_bpsso the worst-performing rates surface first. - The result renders as a plain table (
ReportTable) with two footnotes defined on the report itself: only production-rate-method services are included (manual-rate services have nothing to compare against), and a negative Rate Variance means the job took longer than the assumed rate predicted (the rate may be set too aggressively) while positive means the rate may be too conservative.
Date ranges, Eastern time, and large results
- Everything runs on the org's own clock, not the viewer's. Every date and time a report evaluates — the relative presets (Today, Yesterday, Month to Date, Year to Date), a custom From/To pair, a bare date typed into a filter — is interpreted on that org's configured operating timezone (
organizations.timezone, editable in Settings > Organization; every existing org defaults to America/New_York). The report-run route resolves it server-side viagetMyTimeZone()and threads it into everyanalysis()call — a filter on a single date matches the whole day in the org's zone, and a visit completed at 11:30 PM in that zone lands on that day, not the next UTC day. Scheduled PDFs format their times in the org's zone as well. Two managers in different physical timezones looking at the same org's reports see identical day boundaries; a multi-org staff view would not, by design. - "All Time" means all time. Picking All Time in a date-range filter (or clearing both From and To) sends an explicitly unbounded window. It used to quietly run the report's default preset instead.
- Large results are truncated, and say so. The engine returns at most 5,000 rows. When a report or panel matches more than that, a banner reads "Showing the first N of M rows" and the totals row covers only the returned rows — narrow the date range or add a filter to get a complete total.
- Group subtotals cover the whole result, not just the rows on the page you are looking at, so the on-screen subtotal for a crew or client matches the PDF.
- Exports carry the totals row. CSV, Excel, and PDF downloads end with the same Totals row the on-screen table shows.
Custom analyses ("My Reports")
The My Reports tab (MyReportsList) lists org-saved CustomReport records and links to /crm/admin/reports/analysis/new to build one from scratch via CustomAnalysisBuilder. A custom analysis is the same AnalysisConfig shape a pre-built report's analysis() function produces — pick one of the 19 datasets, pick columns, group-by, aggregates, filters, and sort — except a user assembles it visually instead of it being hard-coded in a definitions file, and it's persisted (name + description + config) rather than shipped in the registry. It executes through the identical crm_run_report RPC path.
A few dataset columns exist specifically so a custom analysis can apply the same rules the pre-built reports do (see the Reports Reference guide for the rules themselves):
- Invoices and Invoice Line Items — an Issued (not draft/void) boolean ("Is Issued"). Filter on it for any revenue or receivables figure; the pre-built reports do.
- Payments — Cash Received (not credit/write-off) ("Is Cash"), Account Credit ("Is Credit"), Net Amount (after refunds), and Processing Fee. A "Collected" number should filter Is Cash = true and sum Net Amount.
Saving or running a custom analysis requires the View Report Center permission, and some datasets additionally require a report permission — see Permissions and gating.
Dashboards vs. individual reports
Inside the Report Center, Custom Dashboards (DashboardsList) are described in the UI itself as "Multi-tab dashboards built from your saved analyses". A Dashboard record bundles one or more CustomReport analyses into tabs, built either from a blank starting point or from one of the entries in DASHBOARD_TEMPLATES — e.g. "Sales Overview" (estimate pipeline, win rate, recent activity), "A/R Overview" (outstanding balances, collections, payment activity), or "Operations Dashboard" — modeled on Service Autopilot's Operations Dashboard, with nine tabs (KPI's, Tickets, Profit / Loss, Upcoming Jobs - no contracts, Won Estimates by Service, Products/Bulk Material, Contracts, Services Insights, Projections) built mostly by embedding reports that already existed elsewhere in the Report Center. A saved dashboard opens at /crm/admin/reports/dashboards/[id], rendered by DashboardViewer.
Notably, that same DashboardViewer component is also mounted at /dashboards/custom/[id] — a user-built dashboard is reachable through either the Report Center's own tab or the top-level Dashboards sidebar, both pointing at the same underlying record and useDashboards() query.
Panel dates. A tab can carry a shared date picker, and each panel chooses whether to follow it. A panel can instead pin its own relative window — Filter to today, Filter to yesterday, Month to date, or Year to date — computed in Eastern time every time the dashboard loads. The seeded templates use this for their annual gauges: "Invoiced Revenue (YTD)", "New Leads YTD", and "New Clients / Converted Leads YTD" are true January-1-to-today figures and do not move when you change the tab's date picker, so the gauge maximum keeps meaning an annual target.
A panel whose dataset the signed-in user isn't allowed to query renders "You don't have permission to view this panel" in place of the chart — see Permissions and gating.
The curated, top-level dashboards
Separate from anything a user builds, the app also ships a fixed set of hand-built dashboard pages under /dashboards/*, listed on /dashboards itself (DashboardsHomePage) and in ReportsSidebar's DASHBOARDS_NAV:
| Dashboard | Route | Gate |
|---|---|---|
| Equipt Dashboard | /dashboards/equipt | Requires the Equipt module |
| Landscapt My Day | /dashboards/myday | Requires the Landscapt module |
| Reports Dashboard | /dashboards/landscapt-reports | Requires the Landscapt module |
| KPI Scorecard | /dashboards/kpis | Requires the Landscapt module, hidden from crew role |
| Financial | /dashboards/financials | Internal org only |
| Labor Efficiency | /dashboards/avb | Internal org only |
| Driver Safety Scores | /dashboards/safety | Internal org only |
| Company Report | /dashboards/crm | Requires the Landscapt module, hidden from crew role |
These plus any user-built Custom Dashboards are what the Dashboards home page and sidebar are enumerating. "Reports Dashboard" is worth calling out: it's not part of the Report Center at all — it renders CRMReports, Landscapt's older, pre-Report-Center built-in reporting page, which the Report Center hub also embeds as its own "Dashboard" tab.
The KPI Scorecard
/dashboards/kpis is an annual goals card: metrics grouped into categories (Financial, Operations, Sales, People by default), each with a Target, an Actual, a progress bar, and a percent weight. Every org gets one scorecard, created from the default layout the first time someone opens the page, and the year selector in the header switches which calendar year you are scoring.
Metrics come in two kinds, and the badge on the Actual cell tells you which:
- Auto — computed live from Landscapt data every time the page loads (invoices, payments, jobs, visits, timesheets, estimates, clients, employees, tickets). Nothing is cached, so the number is never stale. Hover the auto badge for the exact definition. Revenue metrics count issued invoices only (no drafts or voids), and Cash Collected counts cash payments only — no account credits or AR write-offs, net of refunds. A dash means there was nothing to compute from for that year — for example Revenue (Sold) needs jobs with a Date Sold, and Maintenance Retention needs recurring or package jobs that existed before January 1.
- Manual — click the Actual cell and type the value. These are things Landscapt has no source for: NOI and net margin, overhead ratio, AP days, labor efficiency against payroll hours, fleet safety score, eNPS, training hours, training completion, accident-free workdays, absenteeism, plus any custom metric you add.
Targets are always editable (click the cell). Some auto metrics are point-in-time snapshots rather than year totals — AR Outstanding, Open Pipeline, Active Clients, Contract MRR, Open Tickets — and say so in their badge tooltip.
Scoring. Progress is actual ÷ target, capped at 100%. Metrics flagged "lower is better" (AR Days, OT %, Skipped Visit %, …) invert that: meeting or beating the target is 100%, and progress degrades the further you are above it. A category's score is the weight-averaged progress of its metrics that have both a target and an actual; the Overall pill is the plain average of the category scores.
Customize. The Customize button switches the card into an editor where you can:
- Add a metric from the catalog dropdown (grouped by suggested category, each tagged auto or manual) — about sixty are available, including Cash Collected, Gross Profit, Labor % of Revenue, Revenue per Man-Hour, AR Over 60 Days, Visit Completion Rate, Budget vs Actual Hours, Maintenance Retention (overall, residential, commercial), Client Retention, New Hires, Average Tenure, Days Since Last Damage Case.
- Add your own manual metric (name + unit) for anything you track outside the app.
- Remove any metric, including the defaults — it goes back into the dropdown so it can be re-added later.
- Change weights, toggle lower-is-better, reorder rows, rename or add categories, or Reset to default.
Who can do what. Anyone whose role has View Report Centercan open the scorecard and see every number. Changing it — editing a target, typing a manual actual, or anything in Customize — requires Manage Report Center, the same permission that gates building Custom Dashboards and custom analyses. Org admins always have it; of the default roles, Owner and Operations Manager have it and the rest (Accounting, Office Admin, Sales / Account Mgr, Scheduler, Customer Support Rep) are view-only. Without it the Customize button is hidden and the cells are read-only, and the database policies reject the write regardless of the UI. Crew logins have neither permission, so they cannot reach the page at all. Roles are managed under Settings > CRM Settings > Roles.
The layout is saved per org in crm_kpi_scorecards; targets and manual actuals are saved per year in crm_kpi_scorecard_entries. Both are RLS-scoped to the org.
The Company Report
/dashboards/crm is a live, always-current sales and operations snapshot, computed entirely from Landscapt data. It has no filters or date picker — it's always "as of now": year-to-date figures run from January 1 of the current year, and the monthly tables cover the trailing three calendar months, the current one as month-to-date.
Every number comes from the same crm_run_report RPC and rpt_* views the rest of the Report Center uses (rpt_clients, rpt_estimates, rpt_invoices), plus direct queries against payments, invoices, and tickets for the handful of figures — per-client aging buckets, cash-only payment totals, ticket assignee counts — that a simple group-by can't express.
The page has four sections:
- KPI row — Invoiced Revenue, Outstanding A/R, New Clients, and New Leads, year-to-date. The progress bars under Invoiced Revenue, New Clients, and New Leads read the matching Target from that org's KPI Scorecard (same metric keys) — set a target there and it shows up here automatically.
- Sales Dashboard — monthly new-client/lead trend, close ratios and open pipeline by sales rep, a won-estimates leaderboard, and this month's new clients by rep and source.
- Operations — invoices, sales tax, and payments over the trailing three months, open tickets by category and assignee, and unapplied/pre-payment totals.
- Collections / A/R — the standard five-bucket aging breakdown and the ten largest outstanding balances, each tagged OK / Monitor / Action / Escalate by a fixed dollar-threshold rule (see below).
src/lib/company-report/flags.ts and are easy to retune.Gated the same as the rest of the Report Center: view_report_center to open it, Landscapt module required, hidden from crew. There's nothing to edit on this page — no Manage Report Center split, unlike the KPI Scorecard.
Audit trail: per-record tabs and the org-wide log
There are two ways to see who changed what. Most detail panels and dialogs — Clients, Jobs, Invoices, Estimates, Contracts, Packages, Services, Tickets, damage cases, Purchase Orders, Requisitions, Receiving, Projects, Products, and the Equipt side's Assets, Work Orders, PM Schedules, Parts, and Vehicles — have their own Audit Trail tab (AuditTrailTab), scoped to that one record. As of this week that list also includes Employees, Crews, Schedules (the recurrence definitions behind recurring jobs), and the Automation builder — the last one shows a single history for the whole rule because its sequences, triggers, and conditions all roll their entries up to the parent automation. For the record you don't already have open, the Audits section of the Report Center has three reports reading a new org-wide rpt_audit_log view: Audit Log (the full trail, filterable by category, record type, user, and action), Security & Access Audit (permission, user, credential, and approval-chain changes only, defaulting to a 90-day window), and Change Activity by User (a count of changes per person, grouped by record type — useful for spotting an unexpected burst of edits).
What an entry captures. Every changed field on a save is recorded, not just one — a save that changed both status and price used to log only the status branch and silently drop the price change; the generic field diff now always runs alongside any specialized status/qty/price phrasing. Money columns are decoded from cents to dollars automatically. Some events read as a plain-language decision rather than a field diff — an approval step reads "Approval step 2 approved by Dana Reyes — 'ok to order'", a change order reads "Change order CO #99 Extra pavers approved — $4500.00" — and roll up onto the requisition, PO, estimate, or project being approved. Attribution uses the signed-in editor, not the record's original creator, except on the very first insert.
What's deliberately hidden. Credential tables (API keys, third-party integrations, OAuth tokens) are audited, but secret values are redacted — an entry says a credential changed, never what it changed to or from. A user moving between organizations is recorded against the org they left, not the one they joined, so a cross-org move stays visible to the org it happened to.
Coverage is trigger-based, so it can have gaps. An entry only exists for a table with a mapped trigger — that's how the Products screen went for a while without receiving/usage history (the RPC wrote record_type = 'product_item' but the screen read 'product') and how project change orders and schedule edits recorded nowhere a person could read them until this week. Fifteen more Landscapt tables were added to coverage in the same pass: payments and payment allocations, milestones, job services/products/materials, crew member times, chemical applications, contract services, client properties/contacts, employees, roles, discounts, and overhead settings — all rolling up onto the parent record's existing tab. The table also lost its client-writable INSERT policy: previously any authenticated org member could insert a row naming someone else as the actor, so a trail that could be forged wasn't evidence. Writes now only happen through the SECURITY DEFINER trigger functions and the service-role key, making audit_log append-only from the browser.
Exporting, printing, and scheduled delivery
Every pre-built report and custom analysis runs through the same viewer chrome (PrebuiltReportRunner), which offers four output actions once a result has rows:
- CSV —
downloadCSV. - Excel —
downloadXLSX. - PDF —
exportReportPDF, backed by/api/crm/reports/export/pdf. - Print — a plain
window.print()call; the filter bar and page header carry aprint:hiddenclass so only the table prints.
All three downloads end with a Totals row matching the one on screen (per-group subtotals for grouped reports). If the on-screen result was truncated at 5,000 rows, the export is too — and its totals cover only those rows.
Scheduled delivery. Reports flagged as schedulable — currently the five fixed-window Actual v. Budgeted Hours reports (Today, Yesterday, Week to Date, Last Week, Month to Date) — show a Schedule button in the viewer. It sets up daily email delivery of that report as a PDF: enter one or more recipient addresses and pick a Send time (an hour of the day, Eastern). An hourly job delivers each schedule on the first run at or after its scheduled hour — so a 7 AM schedule goes out on the 7 AM run, or the 8 AM run if the 7 AM one fired late — and never twice in the same day. Times inside the PDF are formatted in Eastern.
Permissions and gating
Gating on the reporting surfaces is by subscription plan and org flag, not by a distinct report-level role:
- Module gating —
useModuleAccess("equipt" | "landscapt")checks the org's Stripe plan againstplanIncludesModule; it hides the Equipt Dashboard, Landscapt My Day, and Reports Dashboard cards/links when the org's plan doesn't include that module. The KPI Scorecard and Company Report cards/links require the Landscapt module too. - Internal-only dashboards — Financial, Labor Efficiency, and Driver Safety Scores are gated by
useIsInternalOrg()both in the nav (hidden entirely) and by anInternalOnlyGuardwrapping{children}in the reports layout itself for the paths listed inINTERNAL_ONLY_PATHS— so even a direct URL hit is blocked, not just hidden from the nav. - Crew role — the KPI Scorecard, Company Report, and Financial nav entries set
hideFromCrew,DashboardsHomePageindependently checkscurrentUser.role === "crew"to hide the same cards, andCrewBlockedGuardin the reports layout blocks the office dashboards by URL as well. - Custom Dashboards module gate — both
/dashboards/custom/[id]and the list of custom dashboards shown on the Dashboards home page are wrapped inModuleAccessGuard module="landscapt"/hasLandscaptchecks — Custom Dashboards are a Landscapt-only feature.
The KPI Scorecard is the one reporting surface with a view/edit split: View Report Center to open it, Manage Report Center to change targets, manual actuals, or the layout (see the KPI Scorecard section above).
On top of that, CRM role permissions (CRM Settings > Roles, Reports tab) gate the Report Center itself, and they are enforced server-side, not just hidden in the UI:
- View Report Center is required for custom analyses, saved reports ("My Reports"), Custom Dashboards, and the Graphics Library. Without it the API returns 403 regardless of what the sidebar shows.
- Per-report keys. Most pre-built reports map to a permission of the same name in the role editor's CRM Reports, Scheduling Reports, and Accounting Reports sections (
REPORT_PERMISSION_KEYSinsrc/lib/reports/report-permissions.ts). A report with no entry there is visible to anyone who can reach the Report Center. - Per-dataset keys for ad-hoc queries. Because base-table RLS is org-wide rather than role-aware, a custom analysis or dashboard panel over a sensitive dataset also requires the matching report permission — otherwise anyone with View Report Center could pull pay rates or invoice history a role was meant to hide (
DATASET_PERMISSION_KEYS). Any one of the listed permissions is enough:- Employees — Employee Directory.
- Timesheets — Job Hours Summary or Employee Directory.
- Invoices and Invoice Line Items — Invoiced Income by Client, Invoices with Balances, or A/R Aging Report.
- Payments — Payment Audit Summary, or any of the three invoice permissions above.
- Estimates and Estimate Line Items — View Estimates, Estimates by Stage, or Won Estimates by Service.
- A dashboard panel the signed-in user can't query is not an error state — it renders "You don't have permission to view this panel" and the rest of the dashboard loads normally. Org Admins bypass CRM role checks entirely.
Row-level data scoping still applies through Supabase RLS on whatever tables crm_run_report reads — permissions decide which reports and datasets a role may run; RLS decides which org's rows come back.