Skip to content
AzuAzu

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.
Terminal window
node ace azu:screen SalesReport

That 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.

The demo’s sales report:

app/admin/screens/sales_report.ts (part)
label: 'Sales report',
group: 'Reports',
icon: 'chart-column',
ability: viewReports,
component: 'SalesReport',
  • ability is required. It’s a Bouncer ability object from app/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.
  • component names a component in inertia/admin/components, as fields and slots do.
  • label, group, icon, and order place it in the navigation, like a resource’s.

The demo’s viewReports lets admins in, but not staff:

app/abilities/main.ts
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:

app/admin/screens/sales_report.ts
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 gets { data, screen }, with data typed from the screen’s data function. The demo’s switches the range with a partial reload of data:

inertia/admin/components/sales_report.tsx
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.

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:

start/azu.ts
import azu from '@mezielabs/azu/services/main'
import salesReport from '#admin/screens/sales_report'
azu.registerScreen('sales-report', salesReport)