What goes in a restaurant sales dashboard?
A restaurant sales dashboard reads the day at check grain: what each location sold, how big the average check was, and which dayparts carried it. Two numbers that operators most often get wrong belong here explicitly — comps and voids are not the same thing, and mixing them makes both useless.
Which cards belong on this dashboard?
- Net sales by location, this period vs. the same period last year
- Average check size by location and daypart
- Check count by daypart and day of week
- Item mix: top items by revenue and by units
- Discounts as a share of gross sales
- Comps as their own line, by reason code
- Void rate on checks and on items
- Tip percentage and payment method mix
- Labor cost as a percentage of sales, where labor data is modeled
What data does this dashboard need?
- A check table with location, open and close timestamps, net sales, discount, comp, tip, and a void flag
- A check-item table for menu mix, at item grain with quantity and price
- A location table with name, timezone, and service hours
- Reason codes on comps and voids — without them neither number is actionable
- Optional labor hours and wage cost by location and day for the labor ratio
How do you build it?
- Land the source data in a database — via a managed connector, scheduled API pulls, or a CSV loaded with
mb upload csv— keeping raw IDs, timestamps, currencies, and fee lines intact. - Build models at the grain this dashboard needs, with gross, fee, tax, and net amounts as explicit columns rather than per-card arithmetic.
- Create one saved question per card and certify the shared models so every dashboard downstream inherits the same definitions.
- Add dashboard filters for Date range, location, daypart, order channel (dine-in, takeout, delivery), server or terminal.
- Show data freshness on the dashboard — these sources lag, and every viewer should see by how much.
Example card SQL
WITH checks AS (
SELECT
c.check_id,
c.location_id,
c.net_sales_usd,
c.discount_usd,
c.comp_usd,
c.tip_usd,
c.is_voided,
-- Dayparts are cut on local open time; store the timestamp in the
-- location's timezone or convert it here. Late night spans past
-- midnight (22:00–04:59) so a 1am close isn't filed as breakfast.
CASE
WHEN EXTRACT(HOUR FROM c.opened_at) BETWEEN 5 AND 10 THEN 'breakfast'
WHEN EXTRACT(HOUR FROM c.opened_at) BETWEEN 11 AND 14 THEN 'lunch'
WHEN EXTRACT(HOUR FROM c.opened_at) BETWEEN 15 AND 16 THEN 'afternoon'
WHEN EXTRACT(HOUR FROM c.opened_at) BETWEEN 17 AND 21 THEN 'dinner'
ELSE 'late night'
END AS daypart
FROM pos_checks c
WHERE c.opened_at >= CURRENT_DATE - INTERVAL '28 days'
)
SELECT
l.location_name,
ch.daypart,
COUNT(*) FILTER (WHERE NOT ch.is_voided) AS checks,
ROUND(
SUM(ch.net_sales_usd) FILTER (WHERE NOT ch.is_voided), 2
) AS net_sales,
ROUND(
AVG(ch.net_sales_usd) FILTER (WHERE NOT ch.is_voided), 2
) AS average_check,
-- Discounts reduce the price of a sold item; comps give it away
-- at full value. They are different costs and stay separate.
ROUND(
100.0 * SUM(ch.discount_usd)
/ NULLIF(SUM(ch.net_sales_usd + ch.discount_usd + ch.comp_usd), 0), 1
) AS discount_pct,
ROUND(
100.0 * SUM(ch.comp_usd)
/ NULLIF(SUM(ch.net_sales_usd + ch.discount_usd + ch.comp_usd), 0), 1
) AS comp_pct,
ROUND(
100.0 * COUNT(*) FILTER (WHERE ch.is_voided) / NULLIF(COUNT(*), 0), 1
) AS void_pct,
ROUND(
100.0 * SUM(ch.tip_usd) / NULLIF(SUM(ch.net_sales_usd), 0), 1
) AS tip_pct,
ROUND(
100.0 * SUM(ch.net_sales_usd) FILTER (WHERE NOT ch.is_voided)
/ NULLIF(SUM(SUM(ch.net_sales_usd) FILTER (WHERE NOT ch.is_voided))
OVER (PARTITION BY l.location_name), 0), 1
) AS share_of_location_sales_pct
FROM checks ch
JOIN pos_locations l ON l.location_id = ch.location_id
GROUP BY 1, 2
ORDER BY 1, 2;