Screens
Not every admin page is a list of one model. A sales report, a summary, or a tool needs a page of its own. That’s a screen.
A screen has its own Bouncer ability, and one of three bodies:
- your React component, with data from the server (this page),
- a form Azu renders,
- widgets Azu renders.
Generate one
Section titled “Generate one”node ace azu:screen SalesReportThat writes app/admin/screens/sales_report.ts and its component, inertia/admin/components/sales_report.tsx. Both compile and render as they are. The screen opens at /admin/screens/sales-report, named after its file.
Options
Section titled “Options”The demo’s sales report:
label: 'Sales report',group: 'Reports',icon: 'chart-column',ability: viewReports,component: 'SalesReport',abilityis required. It’s a Bouncer ability object fromapp/abilities/main.ts, not a string. Azu checks it on every request, inside the panel’s access gate. A user who fails it, or whose check throws, gets a 403. They don’t see the screen in the navigation either.componentnames a component ininertia/admin/components, as fields and slots do.label,group,icon, andorderplace it in the navigation, like a resource’s.
The demo’s viewReports lets admins in, but not staff:
export const viewReports = Bouncer.ability((user: User) => { return user.role === 'admin'})data runs on the server for each request, after the ability check. Return plain, JSON-serialisable values.
It’s a lazy Inertia prop. So a partial reload of data computes only that: router.reload({ only: ['data'] }).
The demo’s data totals orders by day for the range in the query string (?days=30). Here’s the whole file:
import app from '@adonisjs/core/services/app'import type { HttpContext } from '@adonisjs/core/http'import type { AzuConfig } from '@mezielabs/azu/types'import { defineScreen } from '@mezielabs/azu'import { DateTime } from 'luxon'import db from '@adonisjs/lucid/services/db'import { viewReports } from '#abilities/main'import Order from '#models/order'
/** The ranges the report offers, in days */export const SALES_RANGES = [7, 30, 90] as const
type Range = (typeof SALES_RANGES)[number]
function rangeOf(ctx: HttpContext): Range { const days = Number(ctx.request.qs().days) return (SALES_RANGES as readonly number[]).includes(days) ? (days as Range) : 7}
/** `12500.50` → 1250050: totals add up in kobo, never as floats */function toMinor(value: string | number): number { const [whole, fraction = ''] = String(value).split('.') return Number(whole) * 100 + Number(fraction.padEnd(2, '0').slice(0, 2))}
const toDecimal = (minor: number) => `${Math.floor(minor / 100)}.${String(minor % 100).padStart(2, '0')}`
/** * Orders totalled by day (FR-20), for admins only (viewReports). The * range comes from the query string (`?days=30`); the component * switches it with a partial reload of `data`. */export default defineScreen({ label: 'Sales report', group: 'Reports', icon: 'chart-column', ability: viewReports, component: 'SalesReport', data: async (ctx) => { const { timezone } = app.config.get<AzuConfig>('azu') const days = rangeOf(ctx) const today = DateTime.now().setZone(timezone).startOf('day') const from = today.minus({ days: days - 1 }) const format = db.connection().dialect.dateTimeFormat
const orders = await Order.query() .select('placedAt', 'total') .where('placedAt', '>=', from.toLocal().toFormat(format)) .whereNot('status', 'cancelled')
// One row per day, in the admin timezone, including days with none. // This totals the range's orders in memory, which keeps the demo // readable and the same on all three databases. A large orders table // should aggregate in SQL instead: group by the day of placed_at in the // admin timezone (Postgres: AT TIME ZONE; MySQL: CONVERT_TZ) and sum // there, so only one row per day comes back. const totals = new Map<string, { orders: number; minor: number }>() for (let day = 0; day < days; day++) { totals.set(from.plus({ days: day }).toISODate()!, { orders: 0, minor: 0 }) } for (const order of orders) { const day = totals.get(order.placedAt.setZone(timezone).toISODate()!) if (!day) continue day.orders++ day.minor += toMinor(order.total) }
const rows = [...totals].map(([day, { orders: count, minor }]) => ({ day, orders: count, total: toDecimal(minor), })) return { days, ranges: SALES_RANGES, rows, orders: rows.reduce((sum, row) => sum + row.orders, 0), total: toDecimal([...totals.values()].reduce((sum, row) => sum + row.minor, 0)), } },})The component
Section titled “The component”The component gets { data, screen }, with data typed from the screen’s data function. The demo’s switches the range with a partial reload of data:
import { router } from '@inertiajs/react'import type { ScreenProps } from '@mezielabs/azu-react'import { formatDecimal } from '@mezielabs/azu/shared'import type salesReport from '#admin/screens/sales_report'
const naira = (value: string) => formatDecimal(value, { style: 'currency', currency: 'NGN' })const day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC',})
/** * Orders totalled by day. The range is in the URL (`?days=30`), and * switching it reloads only the screen's `data`. */export default function SalesReport({ data }: ScreenProps<typeof salesReport>) { function show(days: number) { router.get( `${window.location.pathname}?days=${days}`, {}, { only: ['data'], preserveState: true, preserveScroll: true, replace: true } ) }
return ( <section className="azu-panel" aria-labelledby="sales-report-range"> {/* The panel's header bar: the summary, then the range */} <div className="azu-panel-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 12, }} > <h2 id="sales-report-range" style={{ margin: 0, font: 'inherit' }}> Last {data.days} days: {data.orders} {data.orders === 1 ? 'order' : 'orders'},{' '} {naira(data.total)} </h2> <div role="group" aria-label="Range" style={{ display: 'flex', gap: 8 }}> {data.ranges.map((days) => ( <button key={days} type="button" className={days === data.days ? 'azu-button azu-button-primary' : 'azu-button'} aria-pressed={days === data.days} onClick={() => show(days)} > {days} days </button> ))} </div> </div> <div style={{ overflowX: 'auto' }}> <table className="azu-table" data-sales-report> <caption className="azu-visually-hidden">Orders and totals by day</caption> <thead> <tr> <th className="azu-th" scope="col"> Day </th> <th className="azu-th" scope="col" style={{ textAlign: 'right' }}> Orders </th> <th className="azu-th" scope="col" style={{ textAlign: 'right' }}> Total </th> </tr> </thead> <tbody> {[...data.rows].reverse().map((row) => ( <tr key={row.day} className="azu-row"> <td className="azu-td">{day.format(new Date(`${row.day}T00:00:00Z`))}</td> <td className="azu-td azu-is-numeric">{row.orders}</td> <td className="azu-td azu-is-numeric">{naira(row.total)}</td> </tr> ))} </tbody> </table> </div> </section> )}Component props lists what screen holds.
Loading screens
Section titled “Loading screens”Screens in app/admin/screens load at boot. screens.autoload in config/azu.ts sets the folder, like resources.autoload.
Two screens with the same name stop the app from booting.
With autoload off, register them in start/azu.ts:
import azu from '@mezielabs/azu/services/main'import salesReport from '#admin/screens/sales_report'
azu.registerScreen('sales-report', salesReport)