private SaaS · showcase build · dispatcher-only console

Dispatch

Private SaaS · Next.js + Postgres + Mapbox · 6 role portals · 9 access tiers · 255-input model

The internal name is StaFull; the public showcase is Dispatch.

Built for:
Operators running mobile-fuel fleets where the dispatcher is the source of truth and every other role’s screen is a view into that truth.
Not built for:
Drivers who want a separate native app. The dispatcher console is the product; everything else is a portal into it.

You had four trucks on a whiteboard. Now you have forty, you’re running two shifts, and the whiteboard is fiction by 9 AM. Dispatch replaces it with a single console where the dispatcher sees what the driver, the customer, the operations lead, and the accountant each see — from one place, in real time, with one history.

§ I

The problem

A mobile-fuel operation has six roles that each need a different view of the same fleet — dispatcher, driver, customer, operations lead, accountant, owner — and historically each role gets a separate tool, a separate login, a separate audit trail. The seams between those tools are where the work falls through. A driver completes a delivery; the dispatcher doesn’t know for ten minutes; the customer’s ETA is wrong; the invoice is late.

Dispatch is the opposite shape. One backend, one source of truth, six tailored portals on top of it. Every role sees the same data through their own role’s lens, with access scoped by tier. When a driver completes a delivery, every other portal updates in the same tick.

§ II

Decisions

Three calls that shaped the scope.

  1. cut2025-Q4

    A separate native driver app. The dispatcher console is the source of truth — if every operator needs a second app, the console failed to be that source. Drivers get a portal-flavoured PWA; it covers what they actually need on the road.

  2. kept2025-Q4

    Nine access tiers, not three. Three was elegant; nine matches how authority actually distributes in a real operation, where the difference between “view a route” and “reassign a route” matters and the difference between “reassign” and “reprice” matters more.

  3. kept2026-Q1

    A 255-input financial sandbox living inside the operations portal — not a separate spreadsheet. The owner’s questions about pricing, margin, fleet expansion, fuel-cost shocks all resolve against live data, in the same tool that runs the day’s deliveries.

§ III

System

COREPostgressingle source of truth · WAL · prismaL59-tier access gaterole × tier · scoped capabilityDriverCustomerOperationsAccountantOwnerDispatcherUIMapbox GLlive truck tracking · ETAsCALCSandbox255 inputs · scenario diffLOGAudit historyevery role × every action × timearrows = read · writes go through L5 only
FIGURE 1. One Postgres, six portals, nine tiers — the dispatcher console (brass) is the source of truth; every other role’s screen is a typed view into the same data.
Stack — current pins.
LayerImplementationPurpose
FrontendNext.js 15 + React 19Six role-shaped portals on one app shell
APItRPC + ZodTyped contracts shared between roles
AuthNextAuth + 9-tier scopesPer-role + per-tier capability gates
DatabasePostgres + PrismaFleet · routes · deliveries · invoicing
MapsMapbox GLLive truck tracking · geofence ETAs
SandboxCustom calc engine255 inputs, cached, scenarios diffable
dispatch/server/portals/driver.tstypescript · driver portal query
// Every portal is a typed view over the same Postgres core.
// The dispatcher sees all of it; every other role sees a tier-
// scoped slice. Adding a portal is one Prisma query + one Zod
// shape — the rest of the system doesn't change.
export const driverDay = procedure
  .use(tier({ min: 'driver:read' }))
  .input(z.object({ shift: z.string() }))
  .query(async ({ ctx, input }) => {
    const stops = await prisma.delivery.findMany({
      where: {
        assignedTo: ctx.user.id,           // viewAs scopes here
        shift: input.shift,
        status: { in: ['ready', 'enroute', 'onsite'] },
      },
      include: { customer: true, route: true },
      orderBy: { sequence: 'asc' },
    });
    return stops.map(toDriverShape);       // strip ops/owner fields
  });
response · driver app payloadjson
{
  "shift": "2026-04-28-AM",
  "stops": [
    {
      "id": "del_4f2a",
      "seq": 3,
      "customer": "Riverside Logistics",
      "address": "1842 NE Marine Dr, Portland",
      "eta": "08:42-07:00",
      "gallons_planned": 220,
      "status": "enroute"
    }
  ],
  "_role": "driver",
  "_tier": "driver:read",
  "_redacted": ["pricing", "margin", "owner_notes"]
}
FIGURE. Same Postgres core, role-shaped response. The dispatcher sees pricing and margin; the driver doesn't. Tier check happens before the query, not after.
Dispatch fleet operations console at 7:42 AM PT — eight active trucks, route, speed, fuel, status. Pickups list and Pacific Northwest map below.
FIGURE. The dispatcher console mid-morning. Eight trucks live, two on low-fuel watch, two idle. Every other portal — driver, customer, operations, accountant, owner — is a tier-scoped view of this same table.
§ V

What I’d do differently

The 9-tier access scheme should have been data, not code, from week one. v0 hard-coded the tiers in the auth middleware; v1 moved them to a config table; v2 made them editable in the operations portal. The same shape, three rewrites.

Acknowledgments

Dispatch stands on Next.js, Prisma, Mapbox, Postgres, and the operations team that lent their whiteboards as the reference design for what the console had to replace.

← Index