CourseForward Deployed Engineer: The Complete Course · Module 1: Engineering Floor · part 9 of 85
Part 9 · Module 1: Engineering Floor

Lesson 3: A screen the claims team can trust

20 min read·13 Oct 2026
Module 1 · Lesson 3 · About 60 minutes · Needs: Lesson 2 (a FastAPI app); Node.js 20.19 or newer on your own computer for the React part

The situation

Kickoff is Monday 16 March 2026, and you will spend it shadowing Joan Petrov. Rafael Dias wants something on screen for her: her team's open exceptions, in a table she can filter, with words she uses. Joan saw ClaimBot fail. She will judge the whole engagement by the first screen she sees, not by your API's status codes.

You do not need to be a frontend specialist. You need enough React, a component library and a chart to build that screen in a day, and the habits that stop a screen causing harm.

A real case, first

At 8:07 am on 13 January 2018, a warning officer at the Hawaii Emergency Management Agency (HI-EMA) sent a live ballistic missile alert to the whole state. There was no missile. The US Federal Communications Commission investigated and published its report in April 2018 (FCC, "Report and Recommendations: Hawaii Emergency Management Agency January 13, 2018 False Alert").

The FCC found "a combination of human error and inadequate safeguards". The officer believed the drill was a real attack, so the screen was not the only cause. But the screen did not help.

ONE DROP-DOWN MENU, ONE SCREEN (FCC, 2018) Test missile alert internal test Missile alert live, to the public a wrong pick sits one row away
The live alert and the internal test sat in the same menu on the same screen; red marks the dangerous choice, grey the harmless one.

The officer picked the live template from a drop-down menu. The report says that menu listed the internal "Test missile alert" together with the "Missile alert" on the same screen, while common industry practice keeps the live environment separate.

THE PROMPT IN 2018 WHAT HI-EMA RECOMMENDED "Are you sure that you want to send this Alert?" "...send a 'Real World Ballistic Missile Alert'?" same words for test and live names the action
A confirmation that reads the same for a test and a live action confirms nothing; one that names the action makes the choice visible.

Then came the confirmation. The report: "The prompt contains the same message, irrespective of whether the message is a test or an alert." HI-EMA's own report recommended a prompt that names the action.

8:07 am false alert sent 38 minutes confusion, no correction 8:45 am correction sent
The false alert went out at 8:07 am and the correction at 8:45 am, 38 minutes later.

The correction went out at 8:45 am, 38 minutes later. Your screen proposes money, not alerts, but the lesson carries: a screen is part of the safety system.

What you will be able to do

  • Build a React screen that fetches data and draws loading, error, empty and ready states.
  • Add a filter, a table and a validated form, keeping state in the right place.
  • Move fast with a component library and a chart, and say what question each number answers.
  • Explain why hiding a button is not access control.
  • Check a demo screen for the things that make people trust it, or misuse it.

1. Enough React to build a usable interface (Essential)

React is a widely used way to build interactive pages. You need a small part of it: enough to show data and react to clicks.

App: loads the data, owns status AgeChart bars of working days open ExceptionsTable type filter "all" rows HF-88207, HF-88190, HF-88301
Joan's screen is three components nested inside each other; blue marks your system's parts, white neutral ones.

React builds a page from components: functions that return markup. Joan's screen is three of them: App loads the data, AgeChart draws ages, and ExceptionsTable holds the filter and rows. The markup is JSX, HTML-like with { } for values.

state status, rows, type render(state) your function screen 3 open rows Joan picks "late": new state, draw again
The screen is a function of state: change the state and the whole screen is drawn again, never edited by hand.

The key idea: the screen is a function of state, the data a component remembers. You never edit the page by hand; you change the state and React draws again. First, that idea in plain Python on the shared data.

python
from datetime import date, timedelta

DATA_AS_OF = date(2026, 3, 16)     # the shared data is a snapshot from this morning
BANK_HOLIDAYS_2026 = [date(2026, 1, 1), date(2026, 4, 3), date(2026, 4, 6), date(2026, 5, 4),
                      date(2026, 5, 25), date(2026, 8, 31), date(2026, 12, 25), date(2026, 12, 28)]

def working_days_between(start, end):
    """Working days after `start`, up to and including `end` (weekends and bank holidays skipped)."""
    days, d = 0, start
    while d < end:
        d += timedelta(days=1)
        if d.weekday() < 5 and d not in BANK_HOLIDAYS_2026:
            days += 1
    return days

EXCEPTIONS_MASTER = [
    {"ref": "HF-88102", "type": "late", "opened": "2026-03-09", "owner": "Joan", "colour": "amber", "notes": "gold acct - Rafael says credit without arguing", "closed": "2026-03-11"},
    {"ref": "HF-88207", "type": "damage", "opened": "2026-03-09", "owner": "Dev", "colour": "red", "notes": "DV 6500 so above cap, needs Tom. waiting on photos", "closed": None},
    {"ref": "HF-88190", "type": "missing", "opened": "2026-03-13", "owner": "Joan", "colour": "red", "notes": "depot says on trailer 14?? chase Tues", "closed": None},
    {"ref": "HF-88233", "type": "late", "opened": "2026-03-10", "owner": "Priti", "colour": "green", "notes": "1 day only, no credit due. hospital - reply same day anyway", "closed": "2026-03-10"},
    {"ref": "HF-88301", "type": "late", "opened": "2026-03-16", "owner": "Dev", "colour": "amber", "notes": "5 days late. 10% = 42.00", "closed": None},
]
print(len(EXCEPTIONS_MASTER), "rows,", sum(r["closed"] is None for r in EXCEPTIONS_MASTER), "open")

Code walkthrough

  • The dates helper and Joan's rows are copied from the shared data. working_days_between skips weekends and bank holidays.
  • The print shows 5 rows, 3 open.
python
TMS_CUSTOMERS = {
    "C-1042": "NORTHGATE BUILDING SUPPLIES LTD",
    "C-1077": "Pinder & Sons Hardware",
    "C-1130": "GREENWAY GARDEN CTR",
    "C-1188": "Atlas Office Furniture",
    "C-1203": "ST. MARY'S HOSPITAL TRUST",
}
SHIPMENTS = {
    "HF-88102": {"customer": "C-1042", "promised": "2026-03-03", "delivered": "2026-03-09", "freight_charge": 240.00, "declared_value": None, "weight_kg": 610},
    "HF-88190": {"customer": "C-1130", "promised": "2026-03-05", "delivered": None, "freight_charge": 130.00, "declared_value": None, "weight_kg": 150},
    "HF-88207": {"customer": "C-1188", "promised": "2026-03-06", "delivered": "2026-03-06", "freight_charge": 310.00, "declared_value": 6500.00, "weight_kg": 380},
    "HF-88233": {"customer": "C-1203", "promised": "2026-03-09", "delivered": "2026-03-10", "freight_charge": 75.00, "declared_value": None, "weight_kg": 25},
    "HF-88301": {"customer": "C-1188", "promised": "2026-03-11", "delivered": "2026-03-16", "freight_charge": 420.00, "declared_value": None, "weight_kg": 700},
}

def screen_row(r):
    """One spreadsheet row, shaped for the screen: customer name and age added."""
    s = SHIPMENTS[r["ref"]]
    age = working_days_between(date.fromisoformat(r["opened"]), DATA_AS_OF)
    return {"ref": r["ref"], "type": r["type"], "owner": r["owner"], "customer": TMS_CUSTOMERS[s["customer"]],
            "freight": s["freight_charge"], "age_days": age, "open": r["closed"] is None}

ROWS = [screen_row(r) for r in EXCEPTIONS_MASTER]
print(ROWS[-1])

Code walkthrough

  • TMS_CUSTOMERS and the five SHIPMENTS rows the spreadsheet uses are copied from the shared data.
  • screen_row adds the customer name, freight and age_days (working days open), so the screen never joins tables itself.
  • The print shows our running example, HF-88301: late, Dev, Atlas Office Furniture, freight 420.0, 0 days open.
python
def render(state):
    if state["status"] == "loading":
        return "Loading exceptions..."
    if state["status"] == "error":
        return "Could not reach the claims API. Try again."
    rows = [r for r in state["rows"] if r["open"]]
    if state["type"] != "all":
        rows = [r for r in rows if r["type"] == state["type"]]   # derived, not stored
    if not rows:
        return f"No open {state['type']} exceptions."
    lines = [f"Open exceptions: {len(rows)} (showing {state['type']})"]
    lines += [f"  {r['ref']}  {r['type']:8} {r['owner']:5} {r['customer']}" for r in rows]
    return "\n".join(lines)

state = {"status": "loading", "rows": [], "type": "all"}
print(render(state))
state = {**state, "status": "ready", "rows": ROWS}       # the fetch came back
print(render(state))
state = {**state, "type": "late"}                        # Joan picks "late"
print(render(state))

Code walkthrough

  • render turns the whole state into the whole screen. It only draws.
  • state = {**state, ...} makes a new state with one field changed, as React expects.
  • The prints show the loading message, 3 open exceptions, then only HF-88301 once Joan picks "late".
App state: rows, status props: rows props: rows AgeChart no state ExceptionsTable state: type
Data flows down as props; each piece of state lives in the one component where it changes.

Props are inputs passed down by a parent, such as the rows App hands to the table. Keep each piece of state in the component where it changes: the filter lives in the table, because nothing else needs it.

loading Loading... error Could not reach empty No open late ready 3 open rows one component, four screens to draw
One component must draw four screens; red marks failure and green success, and a demo that only draws the last one breaks on day one.

A screen that only handles success is a demo trap. The network may be slow, the API may be down, and some filters match nothing. Each needs its own words.

first draw status: loading effect runs fetch /api/... Vite proxy to port 8000 setRows draw again after the first draw, not during it
Loading data is an effect: the page draws first, fetches through the proxy, then draws again with the rows.

Loading data is an effect: code that runs after the component draws, for work outside React such as a network call.

tsx
// src/App.tsx
import { useEffect, useState } from "react";
import { ExceptionsTable, type Row } from "./ExceptionsTable";
import { AgeChart } from "./AgeChart";

type Status = "loading" | "error" | "ready";

export default function App() {
  const [rows, setRows] = useState<Row[]>([]);
  const [status, setStatus] = useState<Status>("loading");

  useEffect(() => {
    fetch("/api/exceptions")
      .then((res) => (res.ok ? res.json() : Promise.reject(res.status)))
      .then((data: Row[]) => { setRows(data); setStatus("ready"); })
      .catch(() => setStatus("error"));
  }, []);

  if (status === "loading") return <p>Loading exceptions...</p>;
  if (status === "error") return <p>Could not reach the claims API. Try again.</p>;
  if (rows.length === 0) return <p>No open exceptions.</p>;
  return (
    <main style={{ padding: 24 }}>
      <h1>Open exceptions</h1>
      <AgeChart rows={rows} />
      <ExceptionsTable rows={rows} />
    </main>
  );
}

Code walkthrough

  • useState gives a value and a setter; calling setRows makes React draw App again.
  • useEffect(..., []) runs once after the first draw; the empty list means no inputs to watch.
  • res.ok is false for a 4xx or 5xx reply, so the error screen shows.
  • The early returns are the loading, error and empty screens; the last passes rows down as props.

The tsx blocks do not run in Colab. The author built and ran them locally on 9 October 2026 (Node.js 24.17, Vite 8.3.4, React 19.3.0, Material UI 9.5.0, Recharts 3.10.1).

bash
npm create vite@latest claims-ui -- --template react-ts
cd claims-ui
npm install
npm install @mui/material @emotion/react @emotion/styled recharts
pip install "fastapi[standard]"
fastapi dev main.py          # in one terminal: the API on port 8000
npm run dev                  # in another: the page on http://localhost:5173

Code walkthrough

  • Vite is a build tool; the first line creates a React TypeScript project. It needs Node.js 20.19+ or 22.12+ (checked October 2026).
  • The second npm install adds Material UI and Recharts.
  • main.py is the Python from sections 1 and 2 without the client and print lines.
  • Replace vite.config.ts with:
typescript
// vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": {
        target: "http://127.0.0.1:8000",              // the FastAPI app from this lesson
        rewrite: (path) => path.replace(/^\/api/, ""), // /api/exceptions arrives as /exceptions
      },
    },
  },
});

Code walkthrough

  • proxy sends requests starting /api to the API, so the browser talks to one origin; rewrite strips the prefix. Serving both at Harbourline is for Lesson 4.

2. Forms, tables, filters, state (Essential)

Most screens an FDE builds are a table, a filter and a form. Get the state wrong and the screen shows things that are no longer true.

rows (state) 3 open type (state) "late" filter every draw visible HF-88301 calculated, not stored
The visible rows are derived from two pieces of state on every draw, so they can never disagree with the data.

The filtered rows are not state. They are calculated from two things that are: the rows and the chosen type. React's documentation is blunt about it: if you can calculate something from props or state during rendering, it should not go into state. This is derived state.

Joan types "42,00" browser check red field, button off server check 422: not a number fast, for the person the truth, for everyone
Check a form twice: in the browser for a quick answer, and on the server because anyone can send a request without your page.

A form needs checking twice. The browser checks as Joan types, so she sees the problem at once. The server checks again, because anyone can send a request without your page. Here is the server side: a list endpoint with a filter, and a credit proposal that must match the policy.

python
%pip install -q fastapi httpx
python
from typing import Literal
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI(title="Harbourline claims screen API")

@app.get("/exceptions")
def list_exceptions(type: Literal["all", "damage", "late", "missing", "wrong_address"] = "all"):
    return [r for r in ROWS if r["open"] and type in ("all", r["type"])]

class CreditProposal(BaseModel):
    ref: str = Field(pattern=r"^HF-\d{5}$")
    amount_gbp: float = Field(gt=0)

@app.post("/credits", status_code=201)
def propose_credit(p: CreditProposal):
    row = next((r for r in ROWS if r["ref"] == p.ref and r["open"]), None)
    if row is None:
        raise HTTPException(404, f"No open exception for {p.ref}")
    due = round(0.10 * row["freight"], 2)               # the late-delivery rule: 10% of freight
    if row["type"] != "late" or p.amount_gbp != due:
        raise HTTPException(400, f"Policy credit for {p.ref} is £{due:.2f}")
    return {"ref": p.ref, "amount_gbp": p.amount_gbp, "status": "proposed"}

client = TestClient(app)
print([r["ref"] for r in client.get("/exceptions", params={"type": "late"}).json()])
print(client.post("/credits", json={"ref": "HF-88301", "amount_gbp": 42.00}).json())
bad = client.post("/credits", json={"ref": "HF-88301", "amount_gbp": "42,00"})
print(bad.status_code, bad.json()["detail"][0]["msg"])

Code walkthrough

  • Literal[...] limits the filter to the four types or all.
  • CreditProposal is the form's shape: pattern checks the consignment number, gt=0 refuses zero or less.
  • propose_credit recomputes the policy credit (10% of freight) and refuses any other amount. It does not recheck the 2-working-day rule.
  • The prints show ['HF-88301'], the accepted £42.00 proposal, then 422 for "42,00": a decimal comma, the same trap as the TMS export.
AFTER DEV CLOSES HF-88301 stored copy still shows HF-88301 derived list shows nothing a second truth, now wrong recalculated, still right
A filtered list kept in state went stale the moment the data changed; the derived list stayed correct.

Break it: store the filtered list

Keep a copy of the filtered rows in state, then let the data change.

python
import copy
state = {"rows": copy.deepcopy(ROWS), "type": "late"}
state["visible"] = [r["ref"] for r in state["rows"] if r["open"] and r["type"] == "late"]   # stored copy

for r in state["rows"]:                     # Dev closes HF-88301 after the credit
    if r["ref"] == "HF-88301":
        r["open"] = False

derived = [r["ref"] for r in state["rows"] if r["open"] and r["type"] == state["type"]]
print("stored copy shows:", state["visible"] or "(nothing)")
print("derived shows:   ", derived or "(nothing)")

Code walkthrough

  • copy.deepcopy keeps ROWS untouched; visible is the redundant copy.
  • After the loop closes HF-88301, the stored copy still shows ['HF-88301']; the derived list shows (nothing).
in the data on Joan's screen CONS_NO Consignment CUST Customer owner Owner age_days Working days open
Column headings come from the words the claims team uses, not from the export's field names.

Name the columns in Joan's words, not the export's. "Working days open" beats "age", and it is the target's unit.

3. A component library you can move fast in (Essential)

A component library is a set of ready-made, tested parts: tables, buttons, dialogs, form fields. On an engagement it is how one engineer produces a screen that looks considered in a day.

hand-built component library keyboard and focus yours to build built in screen reader labels yours to add built in spacing and sizes drifts per screen one theme dialogs and menus yours to build ready-made
A component library's value is the dull, hard parts it already does well, not its looks.

The value is not looks. A good library handles keyboard use, focus, screen reader labels and spacing. This lesson uses Material UI (MUI), an open source React library:

tsx
// src/ExceptionsTable.tsx
import { useState } from "react";
import { Chip, MenuItem, Table, TableBody, TableCell, TableHead, TableRow, TextField } from "@mui/material";

export type Row = { ref: string; type: string; owner: string; customer: string; freight: number; age_days: number };
const TYPES = ["all", "damage", "late", "missing", "wrong_address"];

export function ExceptionsTable({ rows }: { rows: Row[] }) {
  const [type, setType] = useState("all");
  const visible = rows.filter((r) => type === "all" || r.type === type);   // derived, not stored
  return (
    <>
      <TextField select size="small" sx={{ minWidth: 180 }} label="Exception type" value={type} onChange={(e) => setType(e.target.value)}>
        {TYPES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
      </TextField>
      <Table size="small">
        <TableHead>
          <TableRow>
            <TableCell>Consignment</TableCell><TableCell>Customer</TableCell><TableCell>Type</TableCell>
            <TableCell>Owner</TableCell><TableCell align="right">Working days open</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {visible.map((r) => (
            <TableRow key={r.ref}>
              <TableCell>{r.ref}</TableCell><TableCell>{r.customer}</TableCell>
              <TableCell><Chip size="small" label={r.type} color={r.age_days > 2 ? "error" : "default"} /></TableCell>
              <TableCell>{r.owner}</TableCell><TableCell align="right">{r.age_days}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </>
  );
}

Code walkthrough

  • Row describes one API row, so TypeScript catches a misspelt field.
  • useState("all") is the filter; visible is derived on every draw.
  • TextField select is a labelled drop-down; sx sets inline style.
  • key={r.ref} lets React track rows. The Chip turns red past 2 working days.
primary normal path warning needs a look error past target success done Chip, Button, Alert, Table
Set four colours once in the theme, each with one meaning, and every component uses them the same way.

Set the look once, in a theme, and every component follows it. Use colour for meaning, not decoration.

tsx
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
import App from "./App";

const theme = createTheme({
  palette: {
    primary: { main: "#2F5D7C" },   // the system, the normal path
    warning: { main: "#B37D22" },   // uncertain, needs a look
    error: { main: "#B4483A" },     // past target, danger
    success: { main: "#3B7A59" },   // done, safe
  },
  shape: { borderRadius: 8 },
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <App />
    </ThemeProvider>
  </StrictMode>,
);

Code walkthrough

  • createTheme sets four palette colours, each with one meaning, and a corner radius.
  • ThemeProvider hands the theme to every MUI component inside it; CssBaseline resets browser defaults.
  • StrictMode runs extra checks in development only.

4. Charts and dashboards (Essential)

Carol will glance at a dashboard, not study it. If a number does not answer a question she already has, it is decoration, and it may be quoted wrongly in a board meeting.

3 How many open now? 5 days Oldest waiting? 18% Where we started 70% Where we are going live from the API, then the shared baseline
Each tile answers one question Carol already has: 3 open, the oldest 5 working days old, from 18% towards 70%.

Write the question first, then the number. How many are open (3)? How long has the oldest waited (5 working days)? Where did we start (18% within 2 days)? Where are we going (70%)?

python
BASELINE = {
    "exceptions_per_week": 1400,
    "claims_clerks": 9,
    "median_days_to_resolve": 6.5,                 # working days
    "share_resolved_within_2_days": 0.18,          # before the engagement
    "wrongly_paid_claims_share": 0.031,            # of paid claims, last 12 months
    "target_share_resolved_within_2_days": 0.70,
    "clerk_approval_limit_gbp": 500,
}
open_rows = [r for r in ROWS if r["open"]]
tiles = {
    "Open now": len(open_rows),
    "Oldest open (working days)": max(r["age_days"] for r in open_rows),
    "Resolved in 2 days (baseline)": f"{BASELINE['share_resolved_within_2_days']:.0%}",
    "Target": f"{BASELINE['target_share_resolved_within_2_days']:.0%}",
}
for name, value in tiles.items():
    print(f"{name:30} {value}")
print("bars:", sorted(((r["ref"], r["age_days"]) for r in open_rows), key=lambda x: -x[1]))

Code walkthrough

  • BASELINE is copied from the shared data; :.0% formats 0.18 as 18%.
  • The tiles print 3, 5, 18% and 70%; the bars, oldest first, are HF-88207 at 5, HF-88190 at 1, HF-88301 at 0.
WORKING DAYS OPEN, 16 MARCH 2026 HF-88207 5 HF-88190 1 HF-88301 0 2-day target
Bars against a target line show at once that HF-88207, open 5 working days, is the one in trouble (red).

A bar chart with a target line answers "which ones are in trouble?" faster than a table. HF-88207 is past the 2-day line, waiting, the sheet says, on photos and on Tom. Bars sorted oldest first make the order obvious.

tsx
// src/AgeChart.tsx
import { Bar, BarChart, CartesianGrid, ReferenceLine, Tooltip, XAxis, YAxis } from "recharts";
import type { Row } from "./ExceptionsTable";

export function AgeChart({ rows }: { rows: Row[] }) {
  const data = rows
    .map((r) => ({ consignment: r.ref, days: r.age_days }))   // only what the chart needs
    .sort((a, b) => b.days - a.days);                         // oldest first
  return (
    <BarChart width={480} height={180} data={data} layout="vertical" margin={{ top: 16, left: 24, bottom: 16 }}>
      <CartesianGrid strokeDasharray="3 3" />
      <XAxis type="number" allowDecimals={false}
        label={{ value: "working days open", position: "insideBottom", offset: -8 }} />
      <YAxis type="category" dataKey="consignment" width={80} />
      <Tooltip />
      <ReferenceLine x={2} stroke="#B4483A" label={{ value: "2-day target", position: "top" }} />
      <Bar dataKey="days" fill="#2F5D7C" />
    </BarChart>
  );
}

Code walkthrough

  • Recharts is an open source chart library built from React components.
  • data keeps two renamed fields, oldest first. layout="vertical" draws horizontal bars; ReferenceLine draws the target.
data = rows field "ref": page goes blank data = {consignment, days} chart draws give a chart only the fields it needs
Rows with a field named "ref" crashed the chart in our run; mapping to two plain fields fixed it.

Break it: pass the rows straight in

The first version of this chart passed rows directly. The page went blank with "Expected ref to be a function, an object returned by React.createRef(), or undefined/null." ref is a name React reserves for itself; we did not trace exactly where Recharts passes it on, but renaming the field to consignment fixed it. Give a chart only the fields it needs.

5. Auth flows in the UI (Recommended)

Everything you deploy at Harbourline must use its single sign-on, and the screen is where people meet it. Never mistake a hidden button for a rule.

Joan opens the screen Harbourline sign-on page back with one-time code server swaps code: identity OpenID Connect, with PKCE for browser apps
Sign-in is a round trip through Harbourline's identity provider (grey: not yours), ending with your server, not the page, holding the identity.

Sign-in is a round trip. The page sends Joan to Harbourline's identity provider; she signs in there; she comes back with a one-time code, which your server exchanges for her identity. This is OpenID Connect (OIDC). For pages running in a browser, the IETF's OAuth security guidance (RFC 9700, 2025) says public clients "MUST use PKCE", an extra check on that code. Module 6 builds it.

DEV APPROVES £6,500 ON HF-88207 screen hides Approve a courtesy direct request skips the screen server returns 403 the control
Hiding the button is a courtesy; a direct request (red) skips the screen, and the server's 403 is what stops a clerk approving £6,500.

The screen can hide "Approve" from a clerk when the amount is over £500. That is a courtesy. The control is on the server, which checks the role on every request.

python
from fastapi import Header

USERS = {"dev": {"name": "Dev Mistry", "role": "clerk"}, "tom": {"name": "Tom Abara", "role": "finance"}}

@app.post("/payments/{ref}/approve")
def approve(ref: str, amount_gbp: float, x_user: str | None = Header(default=None)):
    user = USERS.get(x_user)                  # stand-in for the signed-in user from single sign-on
    if user is None:
        raise HTTPException(401, "Sign in first")
    if amount_gbp > BASELINE["clerk_approval_limit_gbp"] and user["role"] != "finance":
        raise HTTPException(403, f"Above £500: needs the Finance Controller, not {user['name']}")
    return {"ref": ref, "amount_gbp": amount_gbp, "approved_by": user["name"]}

for who in (None, "dev", "tom"):
    headers = {"x-user": who} if who else {}
    r = client.post("/payments/HF-88207/approve", params={"amount_gbp": 6500}, headers=headers)
    print(who, r.status_code, r.json())

Code walkthrough

  • x_user reads an X-User header. It is a stand-in only: anyone can type a header. The real identity comes from the sign-on session.
  • 401 means "we do not know who you are"; 403 means "we know, and you may not".
  • For £6,500 on HF-88207 (its declared value): no user gets 401, Dev gets 403, Tom gets 200.

6. Design sense sufficient for a credible demo (Essential)

You need a screen a careful person would trust, which makes the dangerous action hard to take by accident. The Hawaii report is a checklist.

"Are you sure?" same for test and live TEST DATA: send a £42.00 credit to Atlas Office Furniture for HF-88301? name the environment, amount, customer, consignment
Our confirmation names the environment, the amount, the customer and the consignment, so a wrong click reads as wrong.

Make the confirmation name the action: the environment, the amount, the customer, the consignment. A generic "Are you sure?" trains people to click through.

TEST DATA simulated rows LIVE real money
Test and live must look different at a glance, with words as well as colour: blue for test, red for live.

Make test and live look different at a glance, as the FCC said common industry practice does. A banner with words, not just colour, works for everyone.

02/03/2026 2 March 2026 England, day first 3 February 2026 US default, month first
The same characters are two different dates; Harbourline writes day first, so the screen must too.

Write dates day first and money with a pound sign. Harbourline's export writes 02/03/2026 for 2 March; a US default would read 3 February.

python
from datetime import datetime

def confirm_text(env, ref, amount, customer):
    """A confirmation that names the environment, the action, the amount and the customer."""
    return f"{env}: send a £{amount:,.2f} late-delivery credit to {customer} for {ref}?"

def generic_prompt(env):
    return "Are you sure that you want to send this Alert?"   # the same words whatever env is

row = next(r for r in ROWS if r["ref"] == "HF-88301")
credit = round(0.10 * row["freight"], 2)
test = confirm_text("TEST DATA", row["ref"], credit, row["customer"])
live = confirm_text("LIVE", row["ref"], credit, row["customer"])
print("generic prompts identical:", generic_prompt("TEST") == generic_prompt("LIVE"))
print("named prompts identical:", test == live)
print(test)
print("day first:", date.fromisoformat(SHIPMENTS["HF-88301"]["promised"]).strftime("%d/%m/%Y"))
print("US reading of 02/03/2026:", datetime.strptime("02/03/2026", "%m/%d/%Y").strftime("%d %B %Y"))

Code walkthrough

  • generic_prompt ignores its input, like the Hawaii prompt: True, test and live read the same.
  • confirm_text names environment, amount, customer and consignment: False, then the full prompt at £42.00.
  • strftime("%d/%m/%Y") writes 11/03/2026; parsing 02/03/2026 month first gives 03 February 2026.

The same ideas in the screen's credit dialog:

tsx
// src/CreditDialog.tsx
import { useState } from "react";
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
import type { Row } from "./ExceptionsTable";

const ENV = import.meta.env.VITE_ENV ?? "TEST DATA";   // set VITE_ENV=LIVE only on the real deployment

export function CreditDialog({ row, onClose }: { row: Row; onClose: () => void }) {
  const due = Math.round(row.freight * 10) / 100;            // 10% of the freight charge
  const [amount, setAmount] = useState(due.toFixed(2));
  const valid = /^\d+(\.\d{1,2})?$/.test(amount) && Number(amount) === due;
  const send = () => fetch("/api/credits", { method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ref: row.ref, amount_gbp: Number(amount) }) }).then(onClose);
  return (
    <Dialog open onClose={onClose}>
      <Alert severity={ENV === "LIVE" ? "error" : "info"}>{ENV}</Alert>
      <DialogTitle>Late-delivery credit for {row.ref}</DialogTitle>
      <DialogContent>
        <TextField label="Credit (£)" value={amount} onChange={(e) => setAmount(e.target.value)} error={!valid}
          helperText={valid ? "10% of the freight charge" : `Policy credit is £${due.toFixed(2)}`} />
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose}>Cancel</Button>
        <Button variant="contained" disabled={!valid} onClick={send}>
          {ENV}: send £{amount} credit to {row.customer}
        </Button>
      </DialogActions>
    </Dialog>
  );
}

Code walkthrough

  • VITE_ENV is a build setting; unset, the screen says TEST DATA, the safe default.
  • valid is the browser check. Typing 42,00 turns the field red and disables the button.
  • The button names environment, amount and customer. send posts to /credits; in the author's run it returned 201.

7. Awareness card: deep CSS and animation (Optional)

MUI DIALOG, DEFAULT MOTION 225 ms in 195 ms out already done; nothing to hand-write for a demo
The library already animates its dialog, 225 ms in and 195 ms out, so a demo needs no hand-written motion.

CSS controls layout and look; animation adds motion. For a demo, the library's defaults are enough: MUI's dialog already fades in over 225 ms and out over 195 ms. Hand-written layout grids and custom transitions matter for consumer products, rarely for an internal claims tool. Learn them when a project needs them.

In production

Unboxed, building a back-office planning system with UK councils, wrote up how they used a component library (Unboxed, "Developing a design system", January 2025). They "started with the GOV.UK Design System to get designs into a staging environment quickly", trusting its tested accessible components. Because they were "designing for experienced planning officers who use the system every day", they adapted the accordion that officers found confusing, keeping its accessibility features. They also added automated checks to their CI pipeline that fail if the wrong component is used.

GOV.UK design system staging quickly adapt for daily users CI fails wrong component Unboxed, 2025
Unboxed started from a tested library, adapted it for people who use the system every day, and let CI guard the choice.

The same pattern fits Harbourline's clerks.

What you added to the engagement

  • A React screen of open exceptions with loading, error and empty states, a type filter and a chart of working days open.
  • API endpoints for the list, a validated credit proposal and a role-checked approval.
  • A credit dialog that names the environment, amount and customer before anything is sent.

Check yourself

  1. Why is the filtered list not kept in state?
  2. A clerk cannot see the Approve button for HF-88207. Is the £500 rule enforced?
  3. What two things did the FCC say the Hawaii confirmation prompt failed to do?
  4. Why does the server check the credit amount when the browser already does?
  1. It can be calculated from the rows and the chosen type. A stored copy goes stale: it still showed HF-88301 after Dev closed it.
  2. Only if the server checks it. Hiding the button is a courtesy; the 403 from the API is the control.
  3. It did not let the officer review the message text, and it did not say whether a test or a live alert was about to be sent.
  4. Anyone can send a request without the page. The browser check is for speed; the server check is the truth.

Exercise

Two changes. Add an "Owned by Dev" tile to tiles. Then change propose_credit so that a credit on an exception that is not late is refused with a message that names its type, for example "HF-88207 is a damage exception; credits are for late deliveries". Rerun the API cell, then this check until it says passed.

python
tiles["Owned by Dev"] = tiles.get("Owned by Dev")      # replace with your tile
r = client.post("/credits", json={"ref": "HF-88207", "amount_gbp": 31.00})
ok_tile = tiles["Owned by Dev"] == 2
ok_api = r.status_code == 400 and "damage" in r.json()["detail"]
print("tile:", "passed" if ok_tile else f"not yet ({tiles['Owned by Dev']})")
print("api: ", "passed" if ok_api else f"not yet ({r.json()['detail']})")

Code walkthrough

  • Your tile replaces the first line; Dev owns two open exceptions.
  • Today the endpoint refuses HF-88207 with "Policy credit for HF-88207 is £31.00", which wrongly suggests a credit is due.
  • The check prints not yet and what it saw until both parts pass.

Key terms

TermMeaning
ComponentA function that returns part of a page
StateData a component remembers; changing it redraws the screen
PropsInputs a component receives from its parent
EffectCode that runs after drawing, for work outside React such as fetching
Derived stateA value calculated from state on every draw instead of stored
Component libraryReady-made, tested parts such as tables, dialogs and fields
OpenID ConnectThe sign-in round trip through the customer's identity provider
ViteThe build tool that serves the page and proxies /api in development

Sources

  • Federal Communications Commission, Public Safety and Homeland Security Bureau, "Report and Recommendations: Hawaii Emergency Management Agency January 13, 2018 False Alert", April 2018. https://transition.fcc.gov/Daily_Releases/Daily_Business/2018/db0410/DOC-350119A1.pdf
  • React, "Choosing the State Structure" (avoid redundant state), checked October 2026. https://react.dev/learn/choosing-the-state-structure
  • Unboxed (Laura, Fede and Ben), "Developing a design system", January 2025. https://unboxed.co/blog/developing-a-design-system/
  • IETF, RFC 9700, "Best Current Practice for OAuth 2.0 Security", January 2025. https://www.rfc-editor.org/rfc/rfc9700.html
  • Vite, "Getting Started" (Node.js requirement), checked October 2026. https://vite.dev/guide/