Lesson 3: A screen the claims team can trust
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.
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.
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.
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.
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.
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.
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_betweenskips weekends and bank holidays. - The
printshows5 rows, 3 open.
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_CUSTOMERSand the fiveSHIPMENTSrows the spreadsheet uses are copied from the shared data.screen_rowadds the customer name, freight andage_days(working days open), so the screen never joins tables itself.- The
printshows our running example, HF-88301: late, Dev, Atlas Office Furniture, freight 420.0, 0 days open.
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
renderturns 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".
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.
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.
Loading data is an effect: code that runs after the component draws, for work outside React such as a network call.
// 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
useStategives a value and a setter; callingsetRowsmakes React drawAppagain.useEffect(..., [])runs once after the first draw; the empty list means no inputs to watch.res.okis false for a 4xx or 5xx reply, so the error screen shows.- The early
returns are the loading, error and empty screens; the last passesrowsdown 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).
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:5173Code 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 installadds Material UI and Recharts. main.pyis the Python from sections 1 and 2 without theclientandprintlines.- Replace
vite.config.tswith:
// 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
proxysends requests starting/apito the API, so the browser talks to one origin;rewritestrips 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.
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.
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.
%pip install -q fastapi httpxfrom 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 orall.CreditProposalis the form's shape:patternchecks the consignment number,gt=0refuses zero or less.propose_creditrecomputes 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, then422for"42,00": a decimal comma, the same trap as the TMS export.
Break it: store the filtered list
Keep a copy of the filtered rows in state, then let the data change.
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.deepcopykeepsROWSuntouched;visibleis the redundant copy.- After the loop closes HF-88301, the stored copy still shows
['HF-88301']; the derived list shows(nothing).
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.
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:
// 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
Rowdescribes one API row, so TypeScript catches a misspelt field.useState("all")is the filter;visibleis derived on every draw.TextField selectis a labelled drop-down;sxsets inline style.key={r.ref}lets React track rows. TheChipturns red past 2 working days.
Set the look once, in a theme, and every component follows it. Use colour for meaning, not decoration.
// 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
createThemesets four palette colours, each with one meaning, and a corner radius.ThemeProviderhands the theme to every MUI component inside it;CssBaselineresets browser defaults.StrictModeruns 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.
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%)?
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
BASELINEis copied from the shared data;:.0%formats 0.18 as18%.- 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.
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.
// 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.
datakeeps two renamed fields, oldest first.layout="vertical"draws horizontal bars;ReferenceLinedraws the target.
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.
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.
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.
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_userreads anX-Userheader. It is a stand-in only: anyone can type a header. The real identity comes from the sign-on session.401means "we do not know who you are";403means "we know, and you may not".- For £6,500 on HF-88207 (its declared value): no user gets
401, Dev gets403, Tom gets200.
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.
Make the confirmation name the action: the environment, the amount, the customer, the consignment. A generic "Are you sure?" trains people to click through.
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.
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.
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_promptignores its input, like the Hawaii prompt:True, test and live read the same.confirm_textnames environment, amount, customer and consignment:False, then the full prompt at £42.00.strftime("%d/%m/%Y")writes11/03/2026; parsing02/03/2026month first gives03 February 2026.
The same ideas in the screen's credit dialog:
// 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_ENVis a build setting; unset, the screen saysTEST DATA, the safe default.validis the browser check. Typing42,00turns the field red and disables the button.- The button names environment, amount and customer.
sendposts to/credits; in the author's run it returned201.
7. Awareness card: deep CSS and animation (Optional)
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.
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
- Why is the filtered list not kept in state?
- A clerk cannot see the Approve button for HF-88207. Is the £500 rule enforced?
- What two things did the FCC say the Hawaii confirmation prompt failed to do?
- Why does the server check the credit amount when the browser already does?
- 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.
- Only if the server checks it. Hiding the button is a courtesy; the
403from the API is the control. - 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.
- 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.
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 yetand what it saw until both parts pass.
Key terms
| Term | Meaning |
|---|---|
| Component | A function that returns part of a page |
| State | Data a component remembers; changing it redraws the screen |
| Props | Inputs a component receives from its parent |
| Effect | Code that runs after drawing, for work outside React such as fetching |
| Derived state | A value calculated from state on every draw instead of stored |
| Component library | Ready-made, tested parts such as tables, dialogs and fields |
| OpenID Connect | The sign-in round trip through the customer's identity provider |
| Vite | The 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/