Appearance
Dashboard KPIs
The twelve indicators on the shop dashboard: revenue and profitability for the owner, workshop throughput for the manager, and cash position for accounting.
The one rule
A KPI never queries the ledger on its own terms. Every figure resolves through the same services the accounting report pages use, so a dashboard tile and the report behind it cannot disagree. When a KPI needs math that lives inside a report action, that math is extracted into a service both call — it is never copied.
That is why adding the KPIs moved three pieces of logic out of report actions:
| Service | Extracted from | Shared by |
|---|---|---|
LaborReportService | GetLaborEfficiency | labor efficiency report, SHOP_EFFICIENCY, REVENUE_PER_BILLED_HOUR |
ReceivablesReportService | GetAccountsReceivable | receivables report, UNPAID_INVOICES, DAYS_SALES_OUTSTANDING |
RevenueMixService | GetSalesByType | sales-by-type report, REVENUE_MIX |
All of them sit on LedgerReportService, the double-entry aggregation primitive described in Accounting.
The pieces
App\Entities\KpiType— the twelve cases. Each answers itscategory(),format(), whether it carries ahasSeries()breakdown, whether itisDistribution()(no single value), whether ithasComparison(), and whether itisCacheable(). Category and format travel to the front end as enum metadata, soKpiValueDatadoes not repeat them.App\Entities\KpiCategory—OVERVIEW,OPERATIONS,CASH. Each maps to the permission that gates it.App\Data\KpiValueData— one DTO for all twelve:value,previous_value,variation_percentand aseriesofSalesBreakdownRowData.valueisnullfor a distribution KPI, where the breakdown is the indicator.App\Services\Reports\KpiService—resolve()for one KPI over oneReportPeriod. A singlematchdispatching to twelve small private methods.App\Actions\Kpi\GetDashboardKpis— filtersKpiType::cases()by what the user may see and resolves each one.
Authorization
The three categories are gated by permissions, not roles: UserRole has no Owner or Accountant case, and inventing one would touch far more than the dashboard.
view-kpi-overview, view-kpi-operations and view-kpi-cash are granted to Manager in two places: ShopRolesSeeder for fresh installs, and the add_kpi_permissions migration for databases that already exist. Roles are shop-scoped (roles.shop_id), but these are created without a team, so one grant covers every shop.
The seeder alone is not enough. It runs on a fresh database, so on any environment that already exists the permissions would simply be absent — every manager would load a dashboard with an empty KPI band and no error anywhere. Permission changes need a migration. :::
Permissions inherited from a role reach the front end only because HandleInertiaRequests::withResolvedPermissions() replaces the permissions relation with getAllPermissions(). The relation on its own holds only permissions granted to the user directly, which for a manager is none. :::
Caching
Each resolved KPI is cached for ten minutes under a key that includes a per-shop version:
kpi:{shop}:{version}:{type}:{start}:{end}:{comparison}KpiService::invalidate($shopId) overwrites kpi-version:{shop} with a fresh timestamp, which orphans every key for that shop at once. ClearKpiCacheOnTransactionSaved calls it on TransactionSaved and TransactionDeleted.
A version key rather than Cache::tags(). config/cache.php falls back to the database store when CACHE_STORE is unset, and that store throws on tagged operations — tags would work in development and fail wherever the fallback applies. :::
CASH_COLLECTED_TODAY is never cached: KpiType::isCacheable() returns false for it, because a stale figure there reads as money that never arrived.
LaborReportService and ReceivablesReportService memoise the rows several KPIs share. That memo sits below the cache, so clearing the cache does not reach it — KpiService therefore flushes both whenever the shop's version has moved since it last looked. Without that, a KPI resolved after a transaction write recomputes from rows read before it and reports a figure that was already wrong.
The version key tracks ledger writes, not deployments. A release that changes how a KPI is computed keeps serving the previously cached shape until the ten minute TTL lapses. Call KpiService::invalidate($shopId) after such a deploy rather than waiting, and expect it when a KPI change looks like it did nothing. :::
The revenue trend
REVENUE carries a twelve month series ending on the period, not a breakdown of the period itself — the dashboard asks for one month, and bucketing one month by month is a single point.
Every month in that window gets a bucket, including the ones the ledger has no rows for. A missing month is not a gap the chart can draw around: the line would join the months either side of it and show a climb that never happened.
What can be compared
Only a KPI measured over the period carries a prior-period value: revenue, gross margin, net profit, shop efficiency, revenue per billed hour and average invoice. KpiType::hasComparison() says so.
The rest are balances as of today. ReceivablesReportService has no payment history to rewind, so it reports what is outstanding now whichever period is asked for — comparing unpaid invoices or DSO month over month would print a confident 0% forever. Giving them a real prior value needs a point-in-time receivables history the ledger does not keep. :::
The dashboard defaults to a month-over-month comparison when the request names none, so the cards arrive with a variation rather than a bare figure.
A KPI that cannot be compared carries a context_count instead, so the card still says something about what the balance is made of — the number of invoices behind the unpaid total, how many of them are late, how many payments made up today's collections, how many work orders hold money not yet invoiced. Taxes payable has no count that describes it honestly, so it carries none and the card keeps an empty caption line to stay aligned with its neighbours.
Customer deposits are not booked anywhere yet: TransactionType::DEPOSIT is declared but unused, and account 2500 has no ledger entries. The KPI therefore reads 0 and its count is derived from work orders holding a payment before they are invoiced, which is what a deposit is in accounting terms. If deposits are ever booked to 2500 without a matching uninvoiced work order, the figure and its caption will disagree — and that disagreement is the signal, not a bug in the card. :::
Front end
Components/Kpi/KpiBand.vue groups the KPIs by category and renders a KpiCard for each. KpiCard delegates the value, formatting and variation to the shared Components/StatTile.vue, and mounts KpiChart only when the KPI carries a series.
KpiChart wraps ApexCharts — an area sparkline for a trend, a donut for a distribution. It reads its colours from the theme's CSS custom properties and re-reads them when dark mode toggles, so there is no second palette to keep in sync.
ApexCharts adds roughly 900 kB to the vendor chunk, which every page loads. manualChunks in vite.config.ts deliberately puts all of node_modules in one chunk to avoid circular imports, so splitting the chart library out is not a one-line change: moving vue3-apexcharts with it drags the shared Vue runtime along and makes the new chunk a dependency of every page anyway. Deferring it properly means restructuring the chunk strategy, which is its own piece of work. :::
The dashboard loads the band as a deferred prop with a skeleton fallback, so the work order and invoice panels render without waiting on the ledger.
Pages/Shop/Dashboard.vue renders the KPI band and the invoices-due panel only when the shop has accounting, the awaiting-delivery purchase order panel only with inventory, today's appointments only with appointments, and the new-work-order card only with work_orders (useAuth().hasModule()). That is presentation only: RenderManager still resolves kpis, invoicesDue, invoicesDueSummary and the purchase order props for every manager. See Plans & modules.
Adding a KPI
- Add the case to
KpiTypeand answercategory(),format()and the predicates. - Add its arm to
KpiService::scalar()— through an existing report service, or a new shared one if the math lives in a report action today. - Add labels to
resources/js/Locales/{en,fr}/enums.jsonunderkpi-type. - Add a test to
tests/Feature/Kpi/KpiServiceTest.phpasserting the figure against seeded ledger transactions. The zero-data test covers every case automatically.