From a table to an admin
Pip runs a bike repair shop. The repairs live in a Postgres table that a booking site writes to and a spreadsheet export reads from, and the front desk keeps a paper card per bike because nothing else shows what they need. Pip wants an admin on top of that table, without moving the data or writing an app.
This guide follows that admin from nothing to something the front desk and the workshop both like. Each step is a small change to two config files, and each has a live story you can click through: the same admin, on fourteen repairs of a made-up week.
1. The table, as it is
Section titled “1. The table, as it is”Everything starts with the data config: what the tables are, as they are. protobase scaffold writes it from the database, and Pip tidies it up:
import { f, resource } from 'protobase/schema'
// Pip's bike shop: customers bring bikes in, the workshop repairs them. Two tables of a Postgres database the// shop already has, described as they are.
/** shop.customers */export const customers = resource('customers') .table('shop.customers') .fields({ id: f.integer().readOnly().filterable().sortable().dbDefault(), name: f.text().min(2).filterable().sortable(), phone: f.text().regex(/^\+?[0-9 ]{6,}$/, 'Use digits, spaces and an optional +').optional(), email: f.text().optional().filterable(), country: f.country().default('NL'), createdAt: f.timestamp().readOnly().filterable().sortable().dbDefault(), }) .primaryKey((r) => r.id) .search((r) => [r.name, r.email])
/** shop.repairs */export const repairs = resource('repairs') .table('shop.repairs') .fields({ id: f.integer().readOnly().filterable().sortable().dbDefault(), number: f.text().readOnly().filterable().sortable().dbDefault(), customerId: f.relation('customers').filterable().sortable(), bike: f.text(), problem: f.text(), status: f.enum(['booked', 'waiting', 'working', 'ready', 'collected']).default('booked').filterable().sortable(), mechanic: f.text().optional().filterable().sortable(), estimate: f.decimal({ precision: 8, scale: 2 }).filterable().sortable(), currency: f.currency().default('EUR'), paid: f.boolean().default(false).filterable(), bookedOn: f.date().filterable().sortable(), notes: f.text().optional().column('internal_notes'), legacyRef: null, updatedAt: f.timestamp().readOnly().dbDefault(), }) .primaryKey((r) => r.id) .search((r) => [r.number, r.bike, r.problem]) .validate((repair) => (repair.status === 'collected' && !repair.paid ? [{ field: 'paid', message: 'A bike leaves the shop paid' }] : undefined))The fields follow the columns, with what the database does not say added: which fields can be filtered and sorted, which ones the database fills in (dbDefault), which are read-only. notes lives in a column called internal_notes, and legacyRef: null keeps an old column out for good. The validate rule holds the shop’s one firm rule: a bike does not leave unpaid.
For the admin to list repairs, a view is enough, even an empty one:
import { view } from 'protobase/schema'import type { customers, repairs } from './data'
// Step 1: an entry in the sidebar for each table, and nothing else. Lists and record pages work from the data config.
export const customersView = view<typeof customers>('customers')
export const repairsView = view<typeof repairs>('repairs')That is already a working admin: every column, sorting, paging, a record page and a “New” button.
2. Names and columns
Section titled “2. Names and columns”The list shows everything, including the problem descriptions nobody reads in a list. The front desk wants the repair number, whose bike, what state it is in, and who is on it, newest first.
import { view } from 'protobase/schema'import type { customers, repairs } from './data'
// Step 2: names, what a record is called, the columns the workshop reads, and how values look.
export const customersView = view<typeof customers>('customers') .names({ singular: 'Customer', plural: 'Customers' }) .title((r) => r.name) .list((r) => ({ columns: [r.name, r.phone, r.email], sort: [[r.name, 'asc']] }))
export const repairsView = view<typeof repairs>('repairs') .names({ singular: 'Repair', plural: 'Repairs' }) .title((r) => r.number) .fields((r) => ({ number: r.number.format('code'), customerId: r.customerId.label('Customer'), status: r.status.format('badge'), estimate: r.estimate.prefix('€').decimals(2), bookedOn: r.bookedOn.label('Booked on'), })) .list((r) => ({ columns: [r.number, r.customerId, r.bike, r.status, r.mechanic, r.estimate, r.bookedOn], sort: [[r.bookedOn, 'desc']], search: [r.number, r.bike], }))title makes “R-303” the name of a repair everywhere: breadcrumbs, links, the record page. fields adjusts how values look: statuses become badges, the estimate gets a euro sign and two decimals, and customerId shows as “Customer” (with the customer’s name, since it is a relation). list picks the columns, the starting order, and what the search box looks in.
3. Filters
Section titled “3. Filters”On Saturdays the desk asks the same questions over and over: what is ready, what is Sem working on, what came in this week, what is not paid. Filters answer them in one click.
import { customersView, repairsView as named } from './step-2'
// Step 3: the filters the front desk reaches for, and a chart of bookings above the list.
export { customersView }
export const repairsView = named .filters((r, w) => [ w.facets(r.status), w.facets(r.mechanic, { search: true }), w.dateRange(r.bookedOn, { presets: ['7d', '30d', 'month'] }), w.range(r.estimate, { histogram: true }), w.toggle(r.paid), ]) .chart((r) => ({ field: r.bookedOn, range: '30d', granularity: 'day' }))From here on each step starts from the view of the step before (named is step 2’s), so the file shows only what changes; in a project it is one view in one ui.ts. Each widget writes into the list’s ?filter=, so “ready and unpaid” is a link someone can send. The chart above the list counts repairs per day, following the same filters. In the story, switch on “Paid” and watch the list drop to six.
4. The repair card
Section titled “4. The repair card”The record page still lists every field in one long column. The paper card the desk uses has three parts: the bike and its problem, the workshop’s side, and the money. The layout copies it.
import { l } from 'protobase/schema'import { customersView, repairsView as filtered } from './step-3'
// Step 4: a record page that reads like the paper repair card: what is wrong, who works on it, what it costs.
export { customersView }
export const repairsView = filtered .fields((r) => ({ problem: r.problem.help('In the customer’s words; the mechanic adds findings to the notes.'), notes: r.notes.label('Workshop notes').help('Only the workshop reads these.'), paid: r.paid.help('Set when the customer pays at the desk.'), })) .layout((r) => [ l.section('The bike', [r.customerId, r.bike, r.problem]), l.section('In the workshop', [r.status, r.mechanic, r.notes], { help: 'Pick up a repair by putting your name on it.' }), l.section('Money', [r.estimate, r.currency, r.paid]), l.sidebar([r.status, r.estimate, r.bookedOn]), ]) .saveFeedback('button')Sections group fields under a title, with help where the shop has a habit to explain. The sidebar repeats what matters at a glance and stays read-only. saveFeedback('button') makes Save say when it is done, since the desk prefers that to a toast.
5. Daily moves, and the workshop’s own view
Section titled “5. Daily moves, and the workshop’s own view”Two things happen to every repair: it becomes ready, and it gets collected (and paid). Typing a status into a dropdown is how mistakes happen, so they become buttons. And the mechanics would rather not see prices at all.
import { l } from 'protobase/schema'import { customersView, repairsView as laidOut } from './step-4'
// Step 5: buttons for the moves the shop makes all day, and a quieter view for the mechanics.
export { customersView }
export const repairsView = laidOut.actions((a) => [ a.update('ready', { label: 'Ready for pickup', icon: 'send', set: { status: 'ready' } }), a.update('collect', { label: 'Collected and paid', icon: 'wallet', set: { status: 'collected', paid: true }, confirm: 'Has the customer paid and taken the bike?' }), a.link('customerRepairs', { label: 'Other repairs of this customer', icon: 'file-text', href: '/repairs?filter=customerId%20%3D%20{customerId}' }),])
/** What mechanics get instead: no money, and their own move. */export const workshopRepairsView = laidOut .forRoles(['mechanic']) .list((r) => ({ columns: [r.number, r.bike, r.problem, r.status, r.mechanic], sort: [[r.bookedOn, 'asc']], search: [r.number, r.bike] })) .filters((r, w) => [w.facets(r.status), w.facets(r.mechanic, { search: true }), w.dateRange(r.bookedOn, { presets: ['7d', '30d', 'month'] })]) .layout((r) => [l.section('The bike', [r.bike, r.problem]), l.section('In the workshop', [r.status, r.mechanic, r.notes]), l.sidebar([r.status, r.bookedOn])]) .actions((a) => [a.update('ready', { label: 'Ready for pickup', icon: 'send', set: { status: 'ready' } })])a.update sets fields when its button is pressed, through the same API as Save, so the access rules and the validate rule apply. “Collected and paid” asks first. a.link opens the list of this customer’s repairs, with the customer filled in from the record. Try the three buttons in the story, on R-303:
forRoles(['mechanic']) gives users with the mechanic role their own view: oldest first, no money columns, no money filter, a card without the money section, and only the “Ready for pickup” button. It is built from the step 4 view, so everything else stays the same. A view only decides what is shown; to make the prices truly unreadable for mechanics, give the field an access rule as well (see Data config).
Where Pip is now
Section titled “Where Pip is now”Two short files describe a table and how the shop wants to see it, and the admin follows: lists, filters, a record page, buttons and a view per role. Next steps:
- Data config and UI config list every option used here, and the ones that were not.
- Your first composed page puts what needs attention on one page.