Dashboard

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.

For: Restaurant owners, multi-location operators, GMs, and back-office finance. Refresh: daily after close; hourly during service for live sales views. Source: modeled tables in a Metabase-connected database.

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?

  1. 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.
  2. Build models at the grain this dashboard needs, with gross, fee, tax, and net amounts as explicit columns rather than per-card arithmetic.
  3. Create one saved question per card and certify the shared models so every dashboard downstream inherits the same definitions.
  4. Add dashboard filters for Date range, location, daypart, order channel (dine-in, takeout, delivery), server or terminal.
  5. Show data freshness on the dashboard — these sources lag, and every viewer should see by how much.

Example card SQL

Average check by daypart with comps and voids separatedPostgreSQL
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;

Dashboards

Integrations

Metrics

Analytics

FAQ

What's the difference between a comp and a void?
A comp is food that was made, served, and given away — the cost is real and it belongs in cost of goods. A void is an item or check removed before it was fulfilled, usually a keying error or a cancelled order. Rolling them together makes a kitchen-quality problem look like a training problem, or hides theft inside ordinary corrections. Keep separate cards and require reason codes on both.
Why does average check drop when sales are up?
Usually mix, not price. More takeout and delivery orders, or a strong lunch daypart, pull average check size down while total sales rise. Always break the number by daypart and channel before reacting to it — the aggregate on its own is a composition artifact.
Can I get POS data out of Toast or Lightspeed for this?
Yes, but through a pipeline, not directly. Metabase reads SQL databases and warehouses, so the POS data has to land somewhere first — a nightly export job, an ETL connector, or scheduled CSV uploads. Model checks, check items, and locations as separate tables in that store; the item-grain table is what makes menu mix and item-level voids possible.