Build a Safe Clinic Scheduling Dashboard
Build a full-stack dashboard that prevents overlapping clinic appointments.
Introduction
30 Second Summary
A clinic day can unravel when two patients are promised the same clinician at the same time. Without one trusted schedule, every change creates more uncertainty.
In this project, you will build ClinicFlow, a local appointment dashboard. The finished app uses React, Express, and PostgreSQL to manage fictional patients while blocking overlapping clinician times.
What You'll Build
You will run a fictional clinic day from one dashboard while overlapping bookings are visibly rejected.
By the end of this project, you'll have:
- A daily clinic dashboard that displays appointment totals for the selected date. It also shows queue status and a searchable fictional patient directory.
- A database-backed booking workflow for the full appointment lifecycle. Overlapping clinician times are rejected before they enter the schedule.
- A role-aware demo that shows each staff type only its permitted controls. The source is ready to publish without exposing the local database password.
- Secret Mission: Stress-test competing bookings. Attempt unauthorized role actions to prove the scheduler holds up.
Are there any prerequisites?
You need a Windows computer with administrator access. No developer tools need to be installed before you begin. ClinicFlow uses fictional records only.
Before We Start
Before the hands-on work begins, take a moment to commit to what you are building and why it matters. This gives your ClinicFlow dashboard a clear purpose before you set up any tools.
Prepare the Windows Development Environment
ClinicFlow has several local layers. A missing tool in one layer can stop the first screen from running.
This step gives those layers a shared Windows workspace. You will confirm each tool before the dashboard work begins.
In this step, get ready to:
- Verify the required Windows development tools.
- Create the local ClinicFlow workspace with safe configuration files.
- Install the project dependencies.
Install and verify the Windows tools
Node.js runs the JavaScript tooling for ClinicFlow. PostgreSQL stores the schedule data.
Git tracks changes to the project. Visual Studio Code keeps the files plus terminals in one workspace.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into Windows search.
- Download the recommended User Setup from the official Visual Studio Code page if the app is missing.
- Run the Visual Studio Code installer.
- Press the Windows key after the installation finishes.
- Type Visual Studio Code into Windows search.
- Press Enter to open Visual Studio Code.
- Open the Terminal menu.
- Select New Terminal.
- Check the installed Node.js version by running this command:
node --version
What does this command do?
The command asks Node.js to print its installed version. ClinicFlow uses v24.21.0 LTS.
✔️ I see v24.21.0 LTS
Node.js is ready to install the client plus server dependencies.
ⓧ I see an older version
The installed version is below ClinicFlow's pinned runtime. Update it before creating the workspace.
- Close Visual Studio Code.
- Download v24.21.0 LTS from the official Node.js download page.
- Run the downloaded Windows installer.
- Reopen Visual Studio Code through Windows search.
- Open a fresh terminal from the Terminal menu.
- Check the updated version by running this command:
node --version
What should I see?
The fresh terminal should now report v24.21.0.
ⓧ Command not found
Windows cannot find Node.js yet. Installing the pinned LTS release adds the runtime plus npm.
- Download v24.21.0 LTS from the official Node.js download page.
- Run the downloaded Windows installer.
- Close Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through Windows search.
- Open a fresh terminal from the Terminal menu.
- Confirm the installation by running this command:
node --version
What should I see?
The terminal should report v24.21.0.
- Check the installed Git version in the same terminal by running this command:
git --version
What does this command do?
The command prints the Git version available to the terminal. ClinicFlow uses Git for Windows 2.55.0.
✔️ I see version 2.55.0
Git is ready to track ClinicFlow when you save the project later.
ⓧ I see an older version
The installed Git release needs an update before the repository workflow.
- Close Visual Studio Code.
- Download Git for Windows 2.55.0 from the official Git for Windows page.
- Run the downloaded installer with its default choices.
- Reopen Visual Studio Code through Windows search.
- Open a fresh terminal from the Terminal menu.
- Check the updated version by running this command:
git --version
What should I see?
The terminal should report Git version 2.55.0.
ⓧ Command not found
Windows cannot find Git yet. The official installer adds Git to the command-line environment.
- Download Git for Windows 2.55.0 from the official Git for Windows page.
- Run the downloaded installer with its default choices.
- Close Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through Windows search.
- Open a fresh terminal from the Terminal menu.
- Confirm the installation by running this command:
git --version
What should I see?
The terminal should report Git version 2.55.0.
The PostgreSQL installer also provides pgAdmin 4 plus the command-line tools. The selected database password protects the local postgres superuser.
- Use Windows search to open the system page that lists installed apps.
- Find the PostgreSQL entry in the installed-apps list.
- Read the version shown for PostgreSQL.
✔️ I see version 18.6
PostgreSQL 18.6 is already installed. Confirm that pgAdmin 4 plus the command-line tools are also available through Windows search.
ⓧ I see an older version
The installer asks you to choose a database password. Store it somewhere private because ClinicFlow uses it in a local environment file.
- Download the PostgreSQL 18.6 EDB installer from the official PostgreSQL Windows page.
- Run the downloaded installer with administrator permission.
- Keep PostgreSQL Server selected.
- Keep pgAdmin 4 selected.
- Keep Command Line Tools selected.
- Retain port 5432.
- Choose a password for the postgres database superuser.
- Save that password in a private password manager.
ⓧ PostgreSQL is not installed
The installer asks you to choose a database password. Store it somewhere private because ClinicFlow uses it in a local environment file.
- Download the PostgreSQL 18.6 EDB installer from the official PostgreSQL Windows page.
- Run the downloaded installer with administrator permission.
- Keep PostgreSQL Server selected.
- Keep pgAdmin 4 selected.
- Keep Command Line Tools selected.
- Retain port 5432.
- Choose a password for the postgres database superuser.
- Save that password in a private password manager.
A verified GitHub Free account gives the project a remote home later. Microsoft Edge provides the signed-in browser session for that workflow.
- Press the Windows key to open Windows search.
- Type Microsoft Edge into Windows search.
- Download the browser from the official Microsoft Edge page if it is missing.
- Open Microsoft Edge.
- Visit the GitHub personal account setup page.
- Create a personal account if you do not already have one.
- Choose GitHub Free for the account plan.
- Verify the account email address from the message GitHub sends you.
- Sign in to the verified GitHub account in Microsoft Edge.
A tool still does not open?
Close every Visual Studio Code window after an installation. Reopen the app so its terminal receives the updated Windows path.
If a command still cannot be found, restart Windows before checking it again.
Get targeted help with the tool that Windows cannot find: Help me diagnose a missing Windows development tool.
Create the ClinicFlow workspace
React plus Vite form the browser client. Their package manifest pins every dependency used by the dashboard.
Express plus node-postgres form the API layer. The server keeps its database password in a local file that Git ignores.
- Press the Windows key to open Windows search.
- Type File Explorer into Windows search.
- Press Enter to open File Explorer.
- Select your Desktop in File Explorer.
- Create a folder named clinicflow on the Desktop.
- Return to Visual Studio Code.
- Open the File menu.
- Select Open Folder.
- Select the clinicflow folder from your Desktop.
- Confirm the folder selection.
- Create a client folder from the Visual Studio Code Explorer sidebar.
- Create a server folder from the Explorer sidebar.
You should now see client plus server directly beneath clinicflow in the Explorer sidebar.
- Create a src folder inside client.
- Create .gitignore directly inside clinicflow.
- Paste this ignore configuration into .gitignore:
node_modules/
dist/
.env
What does this file do?
- The node_modules rule keeps downloaded packages out of version control.
- The .env rule keeps the local database password out of future commits.
- Save .gitignore.
- Confirm .gitignore appears at the top level of the Explorer sidebar.
Cannot see .gitignore?
Check that the filename begins with a dot. Make sure Windows did not append .txt.
Ask for help checking the filename: Help me fix my .gitignore filename.
- Create package.json inside the client folder.
- Paste this client package configuration into client/package.json:
{
"name": "clinicflow-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.0"
}
}
What does this file do?
- The dependency entries pin React plus React DOM to 19.3.0.
- The development entries pin Vite to 8.3.0 plus its React plugin to 6.1.1.
- The dev script provides the client command used in the next step.
- Save client/package.json.
- Confirm the file remains free of red JSON syntax markers in Visual Studio Code.
Seeing a JSON warning?
Check every comma plus double quote against the reference. JSON rejects missing commas and trailing commas.
Ask for help locating the syntax issue: Help me fix client/package.json.
- Create package.json inside the server folder.
- Paste this server package configuration into server/package.json:
{
"name": "clinicflow-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.23.0"
}
}
What does this file do?
- The server dependencies pin Express to 5.2.1 plus node-postgres to 8.23.0.
- The dev script starts server.js with Node.js.
- Save server/package.json.
- Confirm the file remains free of red JSON syntax markers in Visual Studio Code.
Server package file showing an error?
Check that both dependency versions are inside double quotes. Confirm the final dependency entry has no trailing comma.
Ask for help comparing the file: Help me fix server/package.json.
- Create index.html inside the client folder.
- Paste this page shell into client/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClinicFlow Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What does this file do?
- The root element gives React a place to render ClinicFlow.
- The module script loads the React entry point from /src/main.jsx.
- Save client/index.html.
- Confirm Visual Studio Code recognizes the file as HTML in the editor status area.
HTML structure showing an error?
Check that every opening HTML tag has the matching closing tag shown in the reference.
Ask for help checking the page shell: Help me fix client/index.html.
- Create vite.config.js inside the client folder.
- Paste this Vite configuration into client/vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
});
What does this file do?
- The React plugin lets Vite process JSX files.
- The proxy sends browser requests beginning with /api to the local Express server.
- Save client/vite.config.js.
- Confirm the file appears directly beneath the client folder in the Explorer sidebar.
Vite configuration showing an error?
Check the import quotes plus closing braces against the reference. Confirm the proxy path begins with a forward slash.
Ask for help comparing the configuration: Help me fix vite.config.js.
- Create main.jsx inside client/src.
- Paste this React entry point into client/src/main.jsx:
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(<App />);
What does this code do?
- The imports bring in React's browser renderer plus the future ClinicFlow component.
- The final line renders App inside the page element named root.
- Save client/src/main.jsx.
- Confirm the file appears inside client/src in the Explorer sidebar.
Imports underlined in main.jsx?
The package imports can remain unresolved until the dependency installation finishes. Check that the local filenames match App.jsx plus styles.css exactly.
Ask for help checking the entry point: Help me fix client/src/main.jsx.
- Create .env.example inside the server folder.
- Paste this shareable database configuration into server/.env.example:
PGUSER=postgres
PGPASSWORD=your-postgres-password
PGHOST=localhost
PGPORT=5432
PGDATABASE=clinic_demo
PORT=3000
What does this file do?
- The PostgreSQL variables describe the local database connection.
- The password remains a harmless placeholder in the shareable example file.
- The API uses local port 3000.
- Save server/.env.example.
- Confirm the example file still contains your-postgres-password.
Environment file looks incomplete?
Check that each setting occupies its own line. Confirm there are no spaces around the equals signs.
Ask for help checking the template: Help me fix server/.env.example.
The next copy contains your real database password. Keep that file local because credentials must never enter version control.
- Copy server/.env.example from the Explorer sidebar.
- Paste the copy into the server folder.
- Rename the copied file to .env.
- Open server/.env in the editor.
- Replace only your-postgres-password with the PostgreSQL password you chose.
- Save server/.env.
Keep the password private
Do not paste the password into server/.env.example. Keep the real value only in the ignored server/.env file.
- Create an empty App.jsx file inside client/src.
- Create an empty styles.css file inside client/src.
You should now see App.jsx plus styles.css beside main.jsx.
- Create an empty schema.sql file inside server.
- Create an empty server.js file inside server.
The Explorer should now show the client entry files plus the server placeholders needed by the next build steps.
Use this comparison to catch a missing line before installing the dependencies.
✔️ Awesome, I've got everything!
Great. Save every open file before moving to the dependency installation.
ⓧ I'd like to double check the full code
Compare each shareable file with the matching reference below. Keep your real server/.env private.
node_modules/
dist/
.env
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClinicFlow Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
{
"name": "clinicflow-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.0"
}
}
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(<App />);
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
});
PGUSER=postgres
PGPASSWORD=your-postgres-password
PGHOST=localhost
PGPORT=5432
PGDATABASE=clinic_demo
PORT=3000
{
"name": "clinicflow-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.23.0"
}
}
Install dependencies and verify the foundation
Each package.json file is a dependency list. npm reads that list and creates a local node_modules folder for the matching side of ClinicFlow.
- Right-click the client folder in the Visual Studio Code Explorer.
- Select Open in Integrated Terminal.
- Install the client dependencies by running:
npm install
What does this command do?
npm reads client/package.json and downloads the pinned client packages. It stores them in client/node_modules.
- Confirm the terminal finishes without an installation error.
- Confirm client/node_modules appears in the Explorer sidebar.
Client installation failed?
Confirm the terminal path ends in client. Check that client/package.json has no JSON syntax warning.
Ask for help with the terminal output: Help me diagnose my client npm install error.
- Right-click the server folder in the Visual Studio Code Explorer.
- Select Open in Integrated Terminal.
- Install the server dependencies by running:
npm install
What does this command do?
npm reads server/package.json and downloads Express plus node-postgres. It stores them in server/node_modules.
- Confirm the terminal finishes without an installation error.
- Confirm server/node_modules appears in the Explorer sidebar.
Server installation failed?
Confirm the terminal path ends in server. Check that server/package.json has no JSON syntax warning.
Ask for help with the terminal output: Help me diagnose my server npm install error.
- Open the Terminal menu.
- Select New Terminal to create a fresh terminal at the clinicflow workspace.
Before you run these checks, which tools do you expect the fresh terminal to find?
- Verify the runtime, version control, database connection, and workspace command by running:
node --version
git --version
pg_isready
code .
What do these checks prove?
- The first two commands confirm that the fresh terminal can find Node.js plus Git.
- The database check asks PostgreSQL whether it can accept a connection.
- The final command asks Visual Studio Code to open the current clinicflow workspace.
You should see Node.js v24.21.0 plus Git 2.55.0 in the terminal. You should also see a Visual Studio Code window open to clinicflow.
✔️ PostgreSQL is accepting connections
That is the local database responding. Your PostgreSQL foundation is ready for the schema in a later step.
ⓧ pg_isready is unavailable
The server can still be verified through pgAdmin 4. This fallback proves PostgreSQL can execute a query.
- Press the Windows key to open Windows search.
- Type pgAdmin 4 into Windows search.
- Press Enter to open pgAdmin 4.
- Connect to the local PostgreSQL server with the password you saved.
- Open the Tools menu.
- Select Query Tool.
- Execute this connection check:
SELECT 1;
What does this query prove?
The query asks PostgreSQL to return the number one. A returned row confirms that pgAdmin 4 can reach the local server.
- Select Execute script.
- Confirm the Data Output panel shows one row.
Cannot connect in pgAdmin 4?
Confirm that the connection uses port 5432 plus the postgres password selected during installation.
Ask for help without sharing the password: Help me troubleshoot my local PostgreSQL connection.
ⓧ PostgreSQL is not accepting connections
The command-line tool is installed, but the local database server did not report readiness.
- Restart Windows so the PostgreSQL service can start cleanly.
- Reopen the clinicflow workspace in Visual Studio Code.
- Open a fresh terminal from the Terminal menu.
- Check PostgreSQL again by running:
pg_isready
What should I see?
A healthy local server reports that it is accepting connections.
Still not accepting connections?
Use pgAdmin 4 to test the local server with the fallback query in the neighbouring tab.
Ask for help with the reported status: Help me restore my local PostgreSQL connection.
- Return to Microsoft Edge.
- Open GitHub.
- Sign in if GitHub asks for your credentials.
- Confirm your signed-in account opens successfully.
That is the foundation complete. ClinicFlow now has a verified runtime, a reachable database, installed dependencies, and a workspace that protects your local password.
Your Windows environment is ready. Next, you will turn these files into the first visible ClinicFlow dashboard.
Build a Daily Dashboard That Can Fail
Your local ClinicFlow workspace is ready in Visual Studio Code. The next goal is a dashboard you can see in Microsoft Edge.
This first React scheduler keeps appointments in browser memory. Repeating the same booking exposes how easily a clinician's time can be duplicated.
In this step, get ready to:
- Build a daily dashboard with fictional patients and temporary appointments.
- Style the dashboard with readable forms and responsive schedule cards.
- Submit the same booking twice to reveal the overlap problem.
Build the first dashboard
The browser needs temporary data before it can display a working clinic schedule. This version stores fictional patients and appointments in memory for a fast visual test.
- Select client/src/App.jsx in the Explorer sidebar.
- Replace the contents of client/src/App.jsx with this temporary dashboard:
import { useEffect, useState } from 'react';
// Keep the demonstration fixed to one fictional clinic day
const DEMO_DATE = '2026-09-16';
const emptyPatient = { fullName: '', phone: '', dateOfBirth: '' };
const emptyBooking = { patientId: '', staffId: '', startsAt: `${DEMO_DATE}T09:00`, endsAt: `${DEMO_DATE}T09:30`, reason: '' };
export default function App() {
// Hold the temporary dashboard data and form values in browser memory
const [role, setRole] = useState('Receptionist'); const [date, setDate] = useState(DEMO_DATE); const [search, setSearch] = useState('');
const [data, setData] = useState({ patients: [], staff: [], appointments: [] }); const [patient, setPatient] = useState(emptyPatient); const [booking, setBooking] = useState(emptyBooking);
const [reschedule, setReschedule] = useState(null); const [message, setMessage] = useState(''); const [refreshKey, setRefreshKey] = useState(0);
// Simulate the data operations that the API will take over later
useEffect(() => { async function loadData() { return { patients: [{ id: 1, fullName: 'Maya Chen', phone: '555-0101', dateOfBirth: '1992-04-14' }, { id: 2, fullName: 'Omar Rivera', phone: '555-0102', dateOfBirth: '1985-11-03' }, { id: 3, fullName: 'Priya Nair', phone: '555-0103', dateOfBirth: '2001-07-22' }], staff: [{ id: 1, fullName: 'Dr. Lena Ortiz' }, { id: 2, fullName: 'Dr. Noah Williams' }], appointments: [] }; } loadData().then(setData); }, []);
async function request(_path, options) { return JSON.parse(options.body); }
function refresh(successMessage) { setMessage(successMessage); setRefreshKey((value) => value + 1); }
async function submitPatient(event) { event.preventDefault(); const body = await request('/api/patients', { body: JSON.stringify(patient) }); setData((current) => ({ ...current, patients: [...current.patients, { ...body, id: Date.now() }] })); setPatient(emptyPatient); refresh('Fictional patient added.'); }
async function submitBooking(event) { event.preventDefault(); const body = await request('/api/appointments', { body: JSON.stringify(booking) }); const selectedPatient = data.patients.find((item) => String(item.id) === body.patientId); const selectedStaff = data.staff.find((item) => String(item.id) === body.staffId); setData((current) => ({ ...current, appointments: [...current.appointments, { ...body, id: Date.now(), patientName: selectedPatient.fullName, phone: selectedPatient.phone, staffName: selectedStaff.fullName, startTime: body.startsAt.slice(11), endTime: body.endsAt.slice(11), status: 'Scheduled' }] })); refresh('Appointment booked.'); }
async function updateStatus(id, status) { setData((current) => ({ ...current, appointments: current.appointments.map((item) => item.id === id ? { ...item, status } : item) })); refresh(`Appointment marked ${status}.`); }
async function submitReschedule(event) { event.preventDefault(); setData((current) => ({ ...current, appointments: current.appointments.map((item) => item.id === reschedule.id ? { ...item, ...reschedule, startTime: reschedule.startsAt.slice(11), endTime: reschedule.endsAt.slice(11), staffName: current.staff.find((staff) => String(staff.id) === reschedule.staffId).fullName, status: 'Scheduled' } : item) })); setReschedule(null); refresh('Appointment rescheduled.'); }
// Derive the visible patient list and daily summary counts
const patientMatches = data.patients.filter((item) => { const term = search.toLowerCase(); return item.fullName.toLowerCase().includes(term) || item.phone.includes(term); });
const scheduled = data.appointments.filter((item) => item.status === 'Scheduled').length;
const waiting = data.appointments.filter((item) => item.status === 'Arrived').length;
const completed = data.appointments.filter((item) => item.status === 'Completed').length;
// Render every dashboard control from the temporary state
return (<main className="shell"><header className="hero"><div><p className="eyebrow">Fictional data only</p><h1>ClinicFlow</h1><p>Daily appointments, patient lookup, and a safer booking workflow.</p></div><label className="role-picker">Demo role<select value={role} onChange={(event) => setRole(event.target.value)}><option>Receptionist</option><option>Clinician</option></select></label></header><p className="notice">This learning prototype is not production authentication and must not contain real patient data.</p>{message && <p className="message" aria-live="polite">{message}</p>}<section className="toolbar card"><label>Schedule date<input type="date" value={date} onChange={(event) => { const nextDate = event.target.value; setDate(nextDate); setBooking((current) => ({ ...current, startsAt: `${nextDate}T09:00`, endsAt: `${nextDate}T09:30` })); }} /></label><label>Search patients or appointments<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Name, phone, clinician, or reason" /></label></section><section className="metrics"><article className="card"><strong>{data.appointments.length}</strong><span>Total today</span></article><article className="card"><strong>{scheduled}</strong><span>Scheduled</span></article><article className="card"><strong>{waiting}</strong><span>Waiting</span></article><article className="card"><strong>{completed}</strong><span>Completed</span></article></section><section className="card"><h2>Daily staff calendar</h2><div className="table-wrap"><table><thead><tr><th>Time</th><th>Patient</th><th>Clinician</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead><tbody>{data.appointments.filter((item) => item.startsAt.startsWith(date) && `${item.patientName} ${item.phone} ${item.staffName} ${item.reason}`.toLowerCase().includes(search.toLowerCase())).map((appointment) => <tr key={appointment.id}><td>{appointment.startTime} to {appointment.endTime}</td><td>{appointment.patientName}<small>{appointment.phone}</small></td><td>{appointment.staffName}</td><td>{appointment.reason}</td><td><span className={`badge ${appointment.status.toLowerCase().replaceAll(' ', '-')}`}>{appointment.status}</span></td><td className="actions">{role === 'Receptionist' ? <><button onClick={() => updateStatus(appointment.id, 'Arrived')}>Arrived</button><button onClick={() => setReschedule({ id: appointment.id, staffId: String(appointment.staffId), startsAt: appointment.startsAt, endsAt: appointment.endsAt })}>Reschedule</button><button className="danger" onClick={() => updateStatus(appointment.id, 'Cancelled')}>Cancel</button></> : <><button onClick={() => updateStatus(appointment.id, 'In Consultation')}>Start visit</button><button onClick={() => updateStatus(appointment.id, 'Completed')}>Complete</button></>}</td></tr>)}{!data.appointments.length && <tr><td colSpan="6">No matching appointments.</td></tr>}</tbody></table></div></section>{role === 'Receptionist' ? <section className="two-column"><form className="card" onSubmit={submitBooking}><h2>Book appointment</h2><label>Patient<select required value={booking.patientId} onChange={(event) => setBooking({ ...booking, patientId: event.target.value })}><option value="">Choose a patient</option>{data.patients.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}</select></label><label>Clinician<select required value={booking.staffId} onChange={(event) => setBooking({ ...booking, staffId: event.target.value })}><option value="">Choose a clinician</option>{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}</select></label><label>Starts<input required type="datetime-local" value={booking.startsAt} onChange={(event) => setBooking({ ...booking, startsAt: event.target.value })} /></label><label>Ends<input required type="datetime-local" value={booking.endsAt} onChange={(event) => setBooking({ ...booking, endsAt: event.target.value })} /></label><label>Reason<input required value={booking.reason} onChange={(event) => setBooking({ ...booking, reason: event.target.value })} /></label><button className="primary" type="submit">Confirm booking</button></form><form className="card" onSubmit={submitPatient}><h2>Add fictional patient</h2><label>Full name<input required value={patient.fullName} onChange={(event) => setPatient({ ...patient, fullName: event.target.value })} /></label><label>Phone<input required value={patient.phone} onChange={(event) => setPatient({ ...patient, phone: event.target.value })} /></label><label>Date of birth<input required type="date" value={patient.dateOfBirth} onChange={(event) => setPatient({ ...patient, dateOfBirth: event.target.value })} /></label><button className="primary" type="submit">Add patient</button></form></section> : <section className="card"><p>Clinicians can update clinical statuses but cannot create patients, book, reschedule, or cancel appointments.</p></section>}{role === 'Receptionist' && reschedule && <form className="card" onSubmit={submitReschedule}><h2>Reschedule appointment #{reschedule.id}</h2><div className="form-grid"><label>Clinician<select required value={reschedule.staffId} onChange={(event) => setReschedule({ ...reschedule, staffId: event.target.value })}>{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}</select></label><label>Starts<input required type="datetime-local" value={reschedule.startsAt} onChange={(event) => setReschedule({ ...reschedule, startsAt: event.target.value })} /></label><label>Ends<input required type="datetime-local" value={reschedule.endsAt} onChange={(event) => setReschedule({ ...reschedule, endsAt: event.target.value })} /></label></div><div className="actions"><button className="primary" type="submit">Save new time</button><button type="button" onClick={() => setReschedule(null)}>Close</button></div></form>}<section className="card"><h2>Patient records</h2><div className="patient-list">{patientMatches.map((item) => <article key={item.id}><strong>{item.fullName}</strong><span>{item.phone}</span><span>DOB: {item.dateOfBirth}</span></article>)}{!patientMatches.length && <p>No matching fictional patients.</p>}</div></section></main>); }
What does this code do?
- The useState calls hold the selected role, date, search term, forms, messages, and temporary clinic data.
- The useEffect call loads three fictional patients plus two fictional clinicians when the page starts.
- The submitBooking function appends every submitted booking to data.appointments without checking its time range.
- The metric calculations turn React state into visible totals for the daily queue.
- Save client/src/App.jsx.
- Return to the VS Code terminal you used for the client dependencies.
- Start the Vite development server by running:
npm run dev
- Copy the Local URL shown by Vite: your Vite Local URL.
- Switch back to Microsoft Edge.
- Paste your Vite Local URL into the address bar.
- Press Enter.
You should see ClinicFlow with fictional patient cards, an empty daily calendar, metric totals, and editable forms. That is your first visible win: the in-memory clinic dashboard is running locally.
Dashboard not loading?
Keep the Vite terminal running while you use the dashboard. If the page reports a syntax problem, compare the opening and closing braces in client/src/App.jsx.
If the page cannot connect, use the exact Local URL printed in the terminal.
Help me debug the ClinicFlow React dashboard.
Style the dashboard
The dashboard already works, but the schedule needs a clear visual hierarchy. Cards, badges, table spacing, and responsive columns make the daily queue easier to scan.
- Select client/src/styles.css in the Explorer sidebar.
- Replace the contents of client/src/styles.css with these dashboard styles:
/* Establish the dashboard typography and page colors */
:root { font-family: Inter, system-ui, sans-serif; color: #183153; background: #eef4f7; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.shell { width: min(1180px, 94vw); margin: 0 auto; padding: 32px 0 64px; }
.hero { display: flex; justify-content: space-between; gap: 24px; align-items: end; margin-bottom: 16px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: 1.15rem; }
.eyebrow { color: #147d75; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.notice { padding: 12px 16px; color: #744b00; background: #fff1c9; border-radius: 10px; }
.message { padding: 12px 16px; background: #daf4e7; border-left: 4px solid #147d75; }
/* Group related controls and schedule information into cards */
.card { background: white; border: 1px solid #d9e3e8; border-radius: 14px; padding: 18px; box-shadow: 0 8px 24px rgba(24, 49, 83, .07); margin-bottom: 18px; }
.toolbar, .two-column, .form-grid, .metrics { display: grid; gap: 16px; }
.toolbar, .two-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 18px; }
.metrics .card { display: grid; gap: 4px; margin: 0; }
.metrics strong { font-size: 2rem; color: #147d75; }
label { display: grid; gap: 6px; margin-bottom: 12px; font-weight: 700; }
input, select { width: 100%; border: 1px solid #b7c7d1; border-radius: 8px; padding: 10px; background: white; }
.role-picker { min-width: 190px; }
.primary { background: #147d75; color: white; border-color: #147d75; }
.danger { color: #a32222; border-color: #e7b4b4; }
button { border: 1px solid #b7c7d1; border-radius: 8px; padding: 8px 10px; background: #f7fafb; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; }
/* Keep tables and records readable across desktop and mobile widths */
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: 11px 8px; border-bottom: 1px solid #e1e8ec; text-align: left; vertical-align: top; } td small { display: block; color: #607584; margin-top: 4px; }
.badge { display: inline-block; border-radius: 999px; padding: 4px 8px; background: #e8eef2; white-space: nowrap; } .badge.arrived { background: #fff1c9; } .badge.in-consultation { background: #d9ebff; } .badge.completed { background: #daf4e7; } .badge.cancelled { background: #f7dede; }
.patient-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } .patient-list article { display: grid; gap: 4px; padding: 12px; background: #f7fafb; border-radius: 10px; }
@media (max-width: 760px) { .hero { align-items: stretch; flex-direction: column; } .toolbar, .two-column, .form-grid, .metrics, .patient-list { grid-template-columns: 1fr; } }
What do these styles change?
- The grid rules arrange the toolbar, metric cards, forms, and patient records into readable columns.
- The badge classes give each queue status a distinct background color.
- The table wrapper preserves access to every schedule column on a narrow screen.
- The media query collapses the dashboard into one column below 760px.
- Save client/src/styles.css.
- Switch back to the ClinicFlow page in Microsoft Edge.
- Refresh the page.
You should see white cards on a pale background, teal summary totals, aligned forms, a scrollable schedule table, and colored status badges.
Styles not appearing?
Confirm that client/src/main.jsx still imports ./styles.css. Check that the stylesheet is saved inside client/src.
Help me fix the ClinicFlow dashboard styles.
✔️ Awesome, I've got everything!
Great. Save both client files before you test the booking behavior.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react';
// Keep the demonstration fixed to one fictional clinic day
const DEMO_DATE = '2026-09-16';
const emptyPatient = { fullName: '', phone: '', dateOfBirth: '' };
const emptyBooking = { patientId: '', staffId: '', startsAt: `${DEMO_DATE}T09:00`, endsAt: `${DEMO_DATE}T09:30`, reason: '' };
export default function App() {
// Hold the temporary dashboard data and form values in browser memory
const [role, setRole] = useState('Receptionist'); const [date, setDate] = useState(DEMO_DATE); const [search, setSearch] = useState('');
const [data, setData] = useState({ patients: [], staff: [], appointments: [] }); const [patient, setPatient] = useState(emptyPatient); const [booking, setBooking] = useState(emptyBooking);
const [reschedule, setReschedule] = useState(null); const [message, setMessage] = useState(''); const [refreshKey, setRefreshKey] = useState(0);
// Simulate the data operations that the API will take over later
useEffect(() => { async function loadData() { return { patients: [{ id: 1, fullName: 'Maya Chen', phone: '555-0101', dateOfBirth: '1992-04-14' }, { id: 2, fullName: 'Omar Rivera', phone: '555-0102', dateOfBirth: '1985-11-03' }, { id: 3, fullName: 'Priya Nair', phone: '555-0103', dateOfBirth: '2001-07-22' }], staff: [{ id: 1, fullName: 'Dr. Lena Ortiz' }, { id: 2, fullName: 'Dr. Noah Williams' }], appointments: [] }; } loadData().then(setData); }, []);
async function request(_path, options) { return JSON.parse(options.body); }
function refresh(successMessage) { setMessage(successMessage); setRefreshKey((value) => value + 1); }
async function submitPatient(event) { event.preventDefault(); const body = await request('/api/patients', { body: JSON.stringify(patient) }); setData((current) => ({ ...current, patients: [...current.patients, { ...body, id: Date.now() }] })); setPatient(emptyPatient); refresh('Fictional patient added.'); }
async function submitBooking(event) { event.preventDefault(); const body = await request('/api/appointments', { body: JSON.stringify(booking) }); const selectedPatient = data.patients.find((item) => String(item.id) === body.patientId); const selectedStaff = data.staff.find((item) => String(item.id) === body.staffId); setData((current) => ({ ...current, appointments: [...current.appointments, { ...body, id: Date.now(), patientName: selectedPatient.fullName, phone: selectedPatient.phone, staffName: selectedStaff.fullName, startTime: body.startsAt.slice(11), endTime: body.endsAt.slice(11), status: 'Scheduled' }] })); refresh('Appointment booked.'); }
async function updateStatus(id, status) { setData((current) => ({ ...current, appointments: current.appointments.map((item) => item.id === id ? { ...item, status } : item) })); refresh(`Appointment marked ${status}.`); }
async function submitReschedule(event) { event.preventDefault(); setData((current) => ({ ...current, appointments: current.appointments.map((item) => item.id === reschedule.id ? { ...item, ...reschedule, startTime: reschedule.startsAt.slice(11), endTime: reschedule.endsAt.slice(11), staffName: current.staff.find((staff) => String(staff.id) === reschedule.staffId).fullName, status: 'Scheduled' } : item) })); setReschedule(null); refresh('Appointment rescheduled.'); }
// Derive the visible patient list and daily summary counts
const patientMatches = data.patients.filter((item) => { const term = search.toLowerCase(); return item.fullName.toLowerCase().includes(term) || item.phone.includes(term); });
const scheduled = data.appointments.filter((item) => item.status === 'Scheduled').length;
const waiting = data.appointments.filter((item) => item.status === 'Arrived').length;
const completed = data.appointments.filter((item) => item.status === 'Completed').length;
// Render every dashboard control from the temporary state
return (<main className="shell"><header className="hero"><div><p className="eyebrow">Fictional data only</p><h1>ClinicFlow</h1><p>Daily appointments, patient lookup, and a safer booking workflow.</p></div><label className="role-picker">Demo role<select value={role} onChange={(event) => setRole(event.target.value)}><option>Receptionist</option><option>Clinician</option></select></label></header><p className="notice">This learning prototype is not production authentication and must not contain real patient data.</p>{message && <p className="message" aria-live="polite">{message}</p>}<section className="toolbar card"><label>Schedule date<input type="date" value={date} onChange={(event) => { const nextDate = event.target.value; setDate(nextDate); setBooking((current) => ({ ...current, startsAt: `${nextDate}T09:00`, endsAt: `${nextDate}T09:30` })); }} /></label><label>Search patients or appointments<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Name, phone, clinician, or reason" /></label></section><section className="metrics"><article className="card"><strong>{data.appointments.length}</strong><span>Total today</span></article><article className="card"><strong>{scheduled}</strong><span>Scheduled</span></article><article className="card"><strong>{waiting}</strong><span>Waiting</span></article><article className="card"><strong>{completed}</strong><span>Completed</span></article></section><section className="card"><h2>Daily staff calendar</h2><div className="table-wrap"><table><thead><tr><th>Time</th><th>Patient</th><th>Clinician</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead><tbody>{data.appointments.filter((item) => item.startsAt.startsWith(date) && `${item.patientName} ${item.phone} ${item.staffName} ${item.reason}`.toLowerCase().includes(search.toLowerCase())).map((appointment) => <tr key={appointment.id}><td>{appointment.startTime} to {appointment.endTime}</td><td>{appointment.patientName}<small>{appointment.phone}</small></td><td>{appointment.staffName}</td><td>{appointment.reason}</td><td><span className={`badge ${appointment.status.toLowerCase().replaceAll(' ', '-')}`}>{appointment.status}</span></td><td className="actions">{role === 'Receptionist' ? <><button onClick={() => updateStatus(appointment.id, 'Arrived')}>Arrived</button><button onClick={() => setReschedule({ id: appointment.id, staffId: String(appointment.staffId), startsAt: appointment.startsAt, endsAt: appointment.endsAt })}>Reschedule</button><button className="danger" onClick={() => updateStatus(appointment.id, 'Cancelled')}>Cancel</button></> : <><button onClick={() => updateStatus(appointment.id, 'In Consultation')}>Start visit</button><button onClick={() => updateStatus(appointment.id, 'Completed')}>Complete</button></>}</td></tr>)}{!data.appointments.length && <tr><td colSpan="6">No matching appointments.</td></tr>}</tbody></table></div></section>{role === 'Receptionist' ? <section className="two-column"><form className="card" onSubmit={submitBooking}><h2>Book appointment</h2><label>Patient<select required value={booking.patientId} onChange={(event) => setBooking({ ...booking, patientId: event.target.value })}><option value="">Choose a patient</option>{data.patients.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}</select></label><label>Clinician<select required value={booking.staffId} onChange={(event) => setBooking({ ...booking, staffId: event.target.value })}><option value="">Choose a clinician</option>{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}</select></label><label>Starts<input required type="datetime-local" value={booking.startsAt} onChange={(event) => setBooking({ ...booking, startsAt: event.target.value })} /></label><label>Ends<input required type="datetime-local" value={booking.endsAt} onChange={(event) => setBooking({ ...booking, endsAt: event.target.value })} /></label><label>Reason<input required value={booking.reason} onChange={(event) => setBooking({ ...booking, reason: event.target.value })} /></label><button className="primary" type="submit">Confirm booking</button></form><form className="card" onSubmit={submitPatient}><h2>Add fictional patient</h2><label>Full name<input required value={patient.fullName} onChange={(event) => setPatient({ ...patient, fullName: event.target.value })} /></label><label>Phone<input required value={patient.phone} onChange={(event) => setPatient({ ...patient, phone: event.target.value })} /></label><label>Date of birth<input required type="date" value={patient.dateOfBirth} onChange={(event) => setPatient({ ...patient, dateOfBirth: event.target.value })} /></label><button className="primary" type="submit">Add patient</button></form></section> : <section className="card"><p>Clinicians can update clinical statuses but cannot create patients, book, reschedule, or cancel appointments.</p></section>}{role === 'Receptionist' && reschedule && <form className="card" onSubmit={submitReschedule}><h2>Reschedule appointment #{reschedule.id}</h2><div className="form-grid"><label>Clinician<select required value={reschedule.staffId} onChange={(event) => setReschedule({ ...reschedule, staffId: event.target.value })}>{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}</select></label><label>Starts<input required type="datetime-local" value={reschedule.startsAt} onChange={(event) => setReschedule({ ...reschedule, startsAt: event.target.value })} /></label><label>Ends<input required type="datetime-local" value={reschedule.endsAt} onChange={(event) => setReschedule({ ...reschedule, endsAt: event.target.value })} /></label></div><div className="actions"><button className="primary" type="submit">Save new time</button><button type="button" onClick={() => setReschedule(null)}>Close</button></div></form>}<section className="card"><h2>Patient records</h2><div className="patient-list">{patientMatches.map((item) => <article key={item.id}><strong>{item.fullName}</strong><span>{item.phone}</span><span>DOB: {item.dateOfBirth}</span></article>)}{!patientMatches.length && <p>No matching fictional patients.</p>}</div></section></main>); }
/* Establish the dashboard typography and page colors */
:root { font-family: Inter, system-ui, sans-serif; color: #183153; background: #eef4f7; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.shell { width: min(1180px, 94vw); margin: 0 auto; padding: 32px 0 64px; }
.hero { display: flex; justify-content: space-between; gap: 24px; align-items: end; margin-bottom: 16px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: 1.15rem; }
.eyebrow { color: #147d75; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.notice { padding: 12px 16px; color: #744b00; background: #fff1c9; border-radius: 10px; }
.message { padding: 12px 16px; background: #daf4e7; border-left: 4px solid #147d75; }
/* Group related controls and schedule information into cards */
.card { background: white; border: 1px solid #d9e3e8; border-radius: 14px; padding: 18px; box-shadow: 0 8px 24px rgba(24, 49, 83, .07); margin-bottom: 18px; }
.toolbar, .two-column, .form-grid, .metrics { display: grid; gap: 16px; }
.toolbar, .two-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 18px; }
.metrics .card { display: grid; gap: 4px; margin: 0; }
.metrics strong { font-size: 2rem; color: #147d75; }
label { display: grid; gap: 6px; margin-bottom: 12px; font-weight: 700; }
input, select { width: 100%; border: 1px solid #b7c7d1; border-radius: 8px; padding: 10px; background: white; }
.role-picker { min-width: 190px; }
.primary { background: #147d75; color: white; border-color: #147d75; }
.danger { color: #a32222; border-color: #e7b4b4; }
button { border: 1px solid #b7c7d1; border-radius: 8px; padding: 8px 10px; background: #f7fafb; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; }
/* Keep tables and records readable across desktop and mobile widths */
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: 11px 8px; border-bottom: 1px solid #e1e8ec; text-align: left; vertical-align: top; } td small { display: block; color: #607584; margin-top: 4px; }
.badge { display: inline-block; border-radius: 999px; padding: 4px 8px; background: #e8eef2; white-space: nowrap; } .badge.arrived { background: #fff1c9; } .badge.in-consultation { background: #d9ebff; } .badge.completed { background: #daf4e7; } .badge.cancelled { background: #f7dede; }
.patient-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } .patient-list article { display: grid; gap: 4px; padding: 12px; background: #f7fafb; border-radius: 10px; }
@media (max-width: 760px) { .hero { align-items: stretch; flex-direction: column; } .toolbar, .two-column, .form-grid, .metrics, .patient-list { grid-template-columns: 1fr; } }
Expose the duplicate booking
The dashboard looks usable, but its booking logic trusts every submission. Testing the same time range twice reveals whether that trust is safe.
- Select Receptionist in the Demo role control.
- Set Schedule date to 2026-09-16.
- Select Maya Chen in the Patient field.
- Select Dr. Lena Ortiz in the Clinician field.
- Set Starts to 2026-09-16T09:00.
- Set Ends to 2026-09-16T09:30.
- Enter Routine consultation in the Reason field.
- Click Confirm booking.
You should see one scheduled row for Maya Chen with Dr. Lena Ortiz from 09:00 to 09:30. The total and scheduled cards should both show 1.
Before you submit the unchanged form again, do you expect the dashboard to reject the duplicate or add another row?
- Click Confirm booking again.
You should see two scheduled rows for the same clinician from 09:00 to 09:30. The total and scheduled cards should both show 2.
Why did the duplicate appear?
The submitBooking function appends each submission directly to the appointment array. It never compares the new time range with an existing clinician booking.
The two rows make the scheduling risk visible. A working form still needs a rule that protects the shared schedule.
You have proved exactly where the in-memory scheduler falls short. Next, you will move the schedule into shared storage and stop overlapping clinician bookings.
Persist the Schedule and Block Overlaps
Your Vite client now makes the scheduling problem visible. Two appointments can claim the same clinician during the same time range.
This step moves the schedule into PostgreSQL. The API checks availability before inserting a booking. A database exclusion constraint provides the final guard when competing requests arrive together.
In this step, get ready to:
- Create and seed the local clinic_demo database with fictional clinic data.
- Build an Express API that reads the schedule through parameterized SQL.
- Connect the React dashboard to PostgreSQL and prove that overlapping bookings are rejected.
Create the PostgreSQL schedule
The temporary arrays disappear whenever the browser reloads. A relational database gives patients and appointments shared identities that survive refreshes.
Why two overlap checks?
The API runs an availability query so it can return a clear booking message. Two requests can still pass that query at nearly the same moment.
The PostgreSQL exclusion constraint checks every insert against committed data. It is the concurrency-safe rule that protects the schedule.
The database password is sensitive. Keep it only in the ignored server/.env file from setup.
- Press Windows to open Windows search.
- Type pgAdmin 4 into the search bar.
- Press Enter to open pgAdmin 4.
- Connect to the local PostgreSQL server using the password saved during setup.
- Select the existing postgres database in the browser tree.
- Open the Tools menu.
- Select Query Tool.
- Create the ClinicFlow database by executing this statement:
CREATE DATABASE clinic_demo;
What does this statement do?
The statement creates an empty database named clinic_demo. The tables and fictional records are added next.
- Select Execute script in the Query Tool toolbar.
- Confirm the Messages panel reports that the database was created.
- Reconnect the Query Tool to the new clinic_demo database.
- Return to the ClinicFlow workspace in Visual Studio Code.
- Create schema.sql inside the server folder using the Explorer sidebar.
- Add the extension and core tables to server/schema.sql by pasting this first chunk:
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS appointments;
DROP TABLE IF EXISTS staff;
DROP TABLE IF EXISTS patients;
CREATE TABLE patients (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
full_name text NOT NULL,
phone text NOT NULL UNIQUE,
date_of_birth date NOT NULL
);
CREATE TABLE staff (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
full_name text NOT NULL,
role text NOT NULL CHECK (role = 'Clinician')
);
What does this schema section do?
- The btree_gist extension lets PostgreSQL combine clinician equality with time-range overlap checks.
- The drop statements make the script repeatable during local development.
- The patient table gives every fictional patient an identity while keeping phone values unique.
- The staff table limits seeded staff records to the Clinician role.
- Add the appointment table below the staff table by pasting this chunk:
CREATE TABLE appointments (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
patient_id integer NOT NULL REFERENCES patients(id),
staff_id integer NOT NULL REFERENCES staff(id),
starts_at timestamp NOT NULL,
ends_at timestamp NOT NULL,
status text NOT NULL DEFAULT 'Scheduled'
CHECK (status IN ('Scheduled', 'Arrived', 'In Consultation', 'Completed', 'Cancelled')),
reason text NOT NULL,
CHECK (ends_at > starts_at),
CONSTRAINT no_staff_overlap EXCLUDE USING gist (
staff_id WITH =,
tsrange(starts_at, ends_at, '[)') WITH &&
) WHERE (status <> 'Cancelled')
);
How does the overlap rule work?
- The foreign keys connect each appointment to one patient and one clinician.
- The end-time check rejects time ranges that finish before they start.
- The no_staff_overlap constraint compares each clinician with the appointment time range.
- The [) bounds include the start and exclude the end. One appointment can therefore begin exactly when another finishes.
- Add the fictional patients and clinicians below the appointment table by pasting this final chunk:
INSERT INTO patients (full_name, phone, date_of_birth) VALUES
('Maya Chen', '555-0101', '1992-04-14'),
('Omar Rivera', '555-0102', '1985-11-03'),
('Priya Nair', '555-0103', '2001-07-22');
INSERT INTO staff (full_name, role) VALUES
('Dr. Lena Ortiz', 'Clinician'),
('Dr. Noah Williams', 'Clinician');
INSERT INTO appointments (patient_id, staff_id, starts_at, ends_at, status, reason)
SELECT p.id, s.id, '2026-09-16 09:00', '2026-09-16 09:30', 'Scheduled', 'Routine consultation'
FROM patients p, staff s
WHERE p.full_name = 'Maya Chen' AND s.full_name = 'Dr. Lena Ortiz';
INSERT INTO appointments (patient_id, staff_id, starts_at, ends_at, status, reason)
SELECT p.id, s.id, '2026-09-16 10:00', '2026-09-16 10:30', 'Arrived', 'Follow-up visit'
FROM patients p, staff s
WHERE p.full_name = 'Omar Rivera' AND s.full_name = 'Dr. Noah Williams';
What does the seed data provide?
The inserts create three fictional patients and two fictional clinicians. Two appointments on 2026-09-16 give the dashboard a persisted starting schedule.
- Save server/schema.sql.
- Return to the Query Tool connected to clinic_demo.
- Load server/schema.sql into the SQL editor.
- Select Execute script.
- Check the Messages panel for successful table creation and inserts.
The database now holds a fictional clinic schedule that survives browser refreshes. The overlap rule lives beside the data it protects.
Schema script did not complete?
Confirm the Query Tool is connected to clinic_demo. Running the schema against the original database puts the tables in the wrong place.
Check that PostgreSQL Server was installed with permission to enable the extension.
Ask for help with the exact message shown in the Query Tool. Help me diagnose my ClinicFlow PostgreSQL schema error.
✔️ The schema ran successfully
Great work. PostgreSQL now owns the schedule and its non-overlap rule.
- Keep the Query Tool connected to clinic_demo for later checks.
ⓧ I'd like to double-check the schema
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS appointments;
DROP TABLE IF EXISTS staff;
DROP TABLE IF EXISTS patients;
CREATE TABLE patients (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
full_name text NOT NULL,
phone text NOT NULL UNIQUE,
date_of_birth date NOT NULL
);
CREATE TABLE staff (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
full_name text NOT NULL,
role text NOT NULL CHECK (role = 'Clinician')
);
CREATE TABLE appointments (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
patient_id integer NOT NULL REFERENCES patients(id),
staff_id integer NOT NULL REFERENCES staff(id),
starts_at timestamp NOT NULL,
ends_at timestamp NOT NULL,
status text NOT NULL DEFAULT 'Scheduled'
CHECK (status IN ('Scheduled', 'Arrived', 'In Consultation', 'Completed', 'Cancelled')),
reason text NOT NULL,
CHECK (ends_at > starts_at),
CONSTRAINT no_staff_overlap EXCLUDE USING gist (
staff_id WITH =,
tsrange(starts_at, ends_at, '[)') WITH &&
) WHERE (status <> 'Cancelled')
);
INSERT INTO patients (full_name, phone, date_of_birth) VALUES
('Maya Chen', '555-0101', '1992-04-14'),
('Omar Rivera', '555-0102', '1985-11-03'),
('Priya Nair', '555-0103', '2001-07-22');
INSERT INTO staff (full_name, role) VALUES
('Dr. Lena Ortiz', 'Clinician'),
('Dr. Noah Williams', 'Clinician');
INSERT INTO appointments (patient_id, staff_id, starts_at, ends_at, status, reason)
SELECT p.id, s.id, '2026-09-16 09:00', '2026-09-16 09:30', 'Scheduled', 'Routine consultation'
FROM patients p, staff s
WHERE p.full_name = 'Maya Chen' AND s.full_name = 'Dr. Lena Ortiz';
INSERT INTO appointments (patient_id, staff_id, starts_at, ends_at, status, reason)
SELECT p.id, s.id, '2026-09-16 10:00', '2026-09-16 10:30', 'Arrived', 'Follow-up visit'
FROM patients p, staff s
WHERE p.full_name = 'Omar Rivera' AND s.full_name = 'Dr. Noah Williams';
What should match?
Compare the extension, three table definitions, exclusion constraint, and fictional inserts with your saved file. Every statement should appear in the same order.
Build the Express scheduling API
The browser needs a shared route into the database. Express handles each request while node-postgres sends parameterized queries through a connection pool.
The ignored server/.env file supplies the connection settings. The API never places the database password in source code.
- Create server.js inside the server folder using the Visual Studio Code Explorer.
- Add the API imports and shared configuration to server/server.js by pasting this chunk:
import { loadEnvFile } from 'node:process';
import express from 'express';
import pg from 'pg';
loadEnvFile();
const { Pool } = pg;
const app = express();
const pool = new Pool();
const port = Number(process.env.PORT ?? 3000);
app.use(express.json());
const asyncRoute = (handler) => (req, res, next) =>
Promise.resolve(handler(req, res, next)).catch(next);
What does the API foundation do?
- The environment loader reads the ignored database settings into the Node.js process.
- The connection pool reuses PostgreSQL connections across requests.
- The JSON middleware makes booking request bodies available through req.body.
- The asyncRoute wrapper forwards rejected promises to the error handler.
- Add the date and booking-window validators below asyncRoute by pasting this chunk:
function validDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function validWindow(startsAt, endsAt) {
const start = Date.parse(startsAt);
const end = Date.parse(endsAt);
return Number.isFinite(start) && Number.isFinite(end) && end > start;
}
Why validate dates first?
The validators reject malformed dates and backwards time ranges before a query reaches PostgreSQL. That keeps bad input away from the scheduling rules.
- Add the availability query below validWindow by pasting this function:
async function findOverlap(staffId, startsAt, endsAt, ignoredId = 0) {
return pool.query(
`SELECT id
FROM appointments
WHERE staff_id = $1
AND id <> $4
AND status <> 'Cancelled'
AND tsrange(starts_at, ends_at, '[)') &&
tsrange($2::timestamp, $3::timestamp, '[)')
LIMIT 1`,
[staffId, startsAt, endsAt, ignoredId],
);
}
What does the availability query check?
- The query searches active appointments belonging to the selected clinician.
- The range overlap operator checks whether the requested window shares any time with an existing window.
- The numbered placeholders keep user-supplied values separate from the SQL statement.
- The optional ignored identifier supports rescheduling in the next step.
- Add the health route below findOverlap by pasting this block:
app.get('/api/health', asyncRoute(async (_req, res) => {
const result = await pool.query('SELECT 1 AS ok');
res.json({ ok: result.rows[0].ok === 1 });
}));
What does the health route prove?
The route runs a small database query before returning its response. A successful result proves that Express can reach clinic_demo using the ignored environment settings.
- Add the data route below the health route by pasting this block:
app.get('/api/data', asyncRoute(async (req, res) => {
const date = String(req.query.date ?? '');
const search = `%${String(req.query.q ?? '').trim()}%`;
if (!validDate(date)) {
return res.status(400).json({ error: 'Use a date in YYYY-MM-DD format.' });
}
const [patientsResult, staffResult, appointmentsResult] = await Promise.all([
pool.query(
`SELECT id,
full_name AS "fullName",
phone,
to_char(date_of_birth, 'YYYY-MM-DD') AS "dateOfBirth"
FROM patients
ORDER BY full_name`,
),
pool.query(
`SELECT id, full_name AS "fullName"
FROM staff
WHERE role = 'Clinician'
ORDER BY full_name`,
),
What does the first half load?
The route validates the selected date before loading patients and clinicians. The search term is wrapped with wildcard characters for the appointment query.
- Continue the same data route by pasting this query immediately below the previous chunk:
pool.query(
`SELECT a.id,
a.patient_id AS "patientId",
a.staff_id AS "staffId",
p.full_name AS "patientName",
p.phone,
s.full_name AS "staffName",
to_char(a.starts_at, 'YYYY-MM-DD"T"HH24:MI') AS "startsAt",
to_char(a.ends_at, 'YYYY-MM-DD"T"HH24:MI') AS "endsAt",
to_char(a.starts_at, 'HH24:MI') AS "startTime",
to_char(a.ends_at, 'HH24:MI') AS "endTime",
a.status,
a.reason
FROM appointments a
JOIN patients p ON p.id = a.patient_id
JOIN staff s ON s.id = a.staff_id
WHERE a.starts_at::date = $1::date
AND (
p.full_name ILIKE $2 OR
p.phone ILIKE $2 OR
s.full_name ILIKE $2 OR
a.reason ILIKE $2
)
ORDER BY a.starts_at`,
[date, search],
),
How does the schedule query work?
The joins turn patient and staff identifiers into names the dashboard can display. The date and search values remain query parameters.
The formatted timestamp fields match the values expected by the existing React controls. The browser receives display-ready times without learning the database column names.
- Finish the data route by pasting this closing block immediately below the appointment query:
]);
res.json({
patients: patientsResult.rows,
staff: staffResult.rows,
appointments: appointmentsResult.rows,
});
}));
What does the response contain?
The API returns patients, clinicians, and appointments in one JSON response. One request can therefore refresh every visible dashboard section.
- Add the booking route below the data route by pasting this first section:
app.post('/api/appointments', allowRoles('Receptionist'), asyncRoute(async (req, res) => {
const patientId = Number(req.body.patientId);
const staffId = Number(req.body.staffId);
const startsAt = String(req.body.startsAt ?? '');
const endsAt = String(req.body.endsAt ?? '');
const reason = String(req.body.reason ?? '').trim();
if (!patientId || !staffId || !reason || !validWindow(startsAt, endsAt)) {
return res.status(400).json({ error: 'Complete every booking field with a valid time range.' });
}
const overlap = await findOverlap(staffId, startsAt, endsAt);
if (overlap.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
What does the pre-insert check do?
The route converts incoming identifiers and validates the requested window. It then calls findOverlap so an obvious collision receives a clear response before insertion.
- Finish the booking route by pasting this block immediately below the overlap check:
const result = await pool.query(
`INSERT INTO appointments
(patient_id, staff_id, starts_at, ends_at, status, reason)
VALUES ($1, $2, $3, $4, 'Scheduled', $5)
ON CONFLICT DO NOTHING
RETURNING id`,
[patientId, staffId, startsAt, endsAt, reason],
);
if (!result.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
res.status(201).json(result.rows[0]);
}));
Why check the insert result too?
A competing request can claim the slot after the availability query finishes. The exclusion constraint rejects that conflict while ON CONFLICT DO NOTHING leaves the result empty.
The empty result becomes the same friendly overlap message. Both predictable and concurrent collisions reach the browser consistently.
- Add the API error handler and listener at the bottom of server/server.js by pasting this block:
app.use((error, _req, res, _next) => {
console.error(error);
res.status(500).json({ error: 'Unexpected server error.' });
});
app.listen(port, () => {
console.log(`ClinicFlow API running at http://localhost:${port}`);
});
How does the API finish requests?
Unexpected route failures reach one error response. The listener starts ClinicFlow on the port loaded from server/.env.
- Add the role helper above validDate by pasting this function:
function allowRoles(...allowedRoles) {
return (req, res, next) => {
const role = req.get('x-demo-role');
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: 'This demo role cannot perform that action.' });
}
next();
};
}
Why does booking read a demo role?
The booking route accepts the Receptionist demo role sent by the client. A later step expands this middleware across the remaining clinic workflows and tests rejected actions directly.
- Save server/server.js.
- Switch to the server terminal from setup.
- Start the Express API by running this command:
npm run dev
What should the server report?
The development script starts server.js with Node.js. The terminal should report that ClinicFlow is running on local port 3000.
- Open a new tab in Microsoft Edge.
- Enter http://localhost:3000/api/health in the address bar.
- Confirm the response reports a successful database health check.
- Replace the address with http://localhost:3000/api/data?date=2026-09-16&q=.
- Confirm the response contains the three fictional patients and two seeded appointments.
That response proves the API is reading ClinicFlow data from PostgreSQL. The schedule now exists independently of the browser tab.
API cannot reach the database?
Confirm server/.env contains the password chosen for the local postgres superuser. Keep the password out of screenshots and chat messages.
Confirm the clinic_demo database exists and PostgreSQL is running on port 5432.
Share only the redacted terminal error when requesting help. Help me debug the ClinicFlow API database connection.
✔️ The API returns clinic data
You have the full server path working. Express can query PostgreSQL and return the persisted schedule.
- Leave the server terminal running while you connect the dashboard.
ⓧ I'd like to double-check the server
import { loadEnvFile } from 'node:process';
import express from 'express';
import pg from 'pg';
loadEnvFile();
const { Pool } = pg;
const app = express();
const pool = new Pool();
const port = Number(process.env.PORT ?? 3000);
app.use(express.json());
const asyncRoute = (handler) => (req, res, next) =>
Promise.resolve(handler(req, res, next)).catch(next);
function allowRoles(...allowedRoles) {
return (req, res, next) => {
const role = req.get('x-demo-role');
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: 'This demo role cannot perform that action.' });
}
next();
};
}
function validDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function validWindow(startsAt, endsAt) {
const start = Date.parse(startsAt);
const end = Date.parse(endsAt);
return Number.isFinite(start) && Number.isFinite(end) && end > start;
}
async function findOverlap(staffId, startsAt, endsAt, ignoredId = 0) {
return pool.query(
`SELECT id
FROM appointments
WHERE staff_id = $1
AND id <> $4
AND status <> 'Cancelled'
AND tsrange(starts_at, ends_at, '[)') &&
tsrange($2::timestamp, $3::timestamp, '[)')
LIMIT 1`,
[staffId, startsAt, endsAt, ignoredId],
);
}
app.get('/api/health', asyncRoute(async (_req, res) => {
const result = await pool.query('SELECT 1 AS ok');
res.json({ ok: result.rows[0].ok === 1 });
}));
app.get('/api/data', asyncRoute(async (req, res) => {
const date = String(req.query.date ?? '');
const search = `%${String(req.query.q ?? '').trim()}%`;
if (!validDate(date)) {
return res.status(400).json({ error: 'Use a date in YYYY-MM-DD format.' });
}
const [patientsResult, staffResult, appointmentsResult] = await Promise.all([
pool.query(
`SELECT id,
full_name AS "fullName",
phone,
to_char(date_of_birth, 'YYYY-MM-DD') AS "dateOfBirth"
FROM patients
ORDER BY full_name`,
),
pool.query(
`SELECT id, full_name AS "fullName"
FROM staff
WHERE role = 'Clinician'
ORDER BY full_name`,
),
pool.query(
`SELECT a.id,
a.patient_id AS "patientId",
a.staff_id AS "staffId",
p.full_name AS "patientName",
p.phone,
s.full_name AS "staffName",
to_char(a.starts_at, 'YYYY-MM-DD"T"HH24:MI') AS "startsAt",
to_char(a.ends_at, 'YYYY-MM-DD"T"HH24:MI') AS "endsAt",
to_char(a.starts_at, 'HH24:MI') AS "startTime",
to_char(a.ends_at, 'HH24:MI') AS "endTime",
a.status,
a.reason
FROM appointments a
JOIN patients p ON p.id = a.patient_id
JOIN staff s ON s.id = a.staff_id
WHERE a.starts_at::date = $1::date
AND (
p.full_name ILIKE $2 OR
p.phone ILIKE $2 OR
s.full_name ILIKE $2 OR
a.reason ILIKE $2
)
ORDER BY a.starts_at`,
[date, search],
),
]);
res.json({
patients: patientsResult.rows,
staff: staffResult.rows,
appointments: appointmentsResult.rows,
});
}));
app.post('/api/appointments', allowRoles('Receptionist'), asyncRoute(async (req, res) => {
const patientId = Number(req.body.patientId);
const staffId = Number(req.body.staffId);
const startsAt = String(req.body.startsAt ?? '');
const endsAt = String(req.body.endsAt ?? '');
const reason = String(req.body.reason ?? '').trim();
if (!patientId || !staffId || !reason || !validWindow(startsAt, endsAt)) {
return res.status(400).json({ error: 'Complete every booking field with a valid time range.' });
}
const overlap = await findOverlap(staffId, startsAt, endsAt);
if (overlap.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
const result = await pool.query(
`INSERT INTO appointments
(patient_id, staff_id, starts_at, ends_at, status, reason)
VALUES ($1, $2, $3, $4, 'Scheduled', $5)
ON CONFLICT DO NOTHING
RETURNING id`,
[patientId, staffId, startsAt, endsAt, reason],
);
if (!result.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
res.status(201).json(result.rows[0]);
}));
app.use((error, _req, res, _next) => {
console.error(error);
res.status(500).json({ error: 'Unexpected server error.' });
});
app.listen(port, () => {
console.log(`ClinicFlow API running at http://localhost:${port}`);
});
What should match?
Compare the imports, pool, helpers, three routes, error handler, and listener with your saved file. The route order should match this reference.
Load persisted data and test the guard
The API now owns the source of truth. The React component needs to load that shared state and send bookings back through the protected route.
- Return to client/src/App.jsx in the open Visual Studio Code workspace.
- Replace the temporary data-loading effect with this PostgreSQL-backed version:
useEffect(() => {
let active = true;
async function loadData() {
try {
const response = await fetch(
`/api/data?date=${encodeURIComponent(date)}&q=${encodeURIComponent(search)}`,
);
const body = await response.json();
if (!response.ok) throw new Error(body.error);
if (active) setData(body);
} catch (error) {
if (active) setMessage(error.message || 'Could not load ClinicFlow.');
}
}
loadData();
return () => {
active = false;
};
}, [date, search, refreshKey]);
What does the data effect do?
- The effect requests patients, clinicians, and appointments whenever the date or search term changes.
- The Vite proxy forwards the /api request to the Express server on port 3000.
- The active flag prevents an older request from updating state after the effect has been cleaned up.
- The refresh key lets a successful mutation request the latest database state.
- Replace the temporary request and refresh helpers with this version:
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
'x-demo-role': role,
},
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Request failed.');
return body;
}
function refresh(successMessage) {
setMessage(successMessage);
setRefreshKey((value) => value + 1);
}
How do client requests work?
The helper sends JSON and includes the selected demo role. Any API error becomes a message that the existing dashboard can display.
The refresh helper updates the visible message before triggering a fresh database read. Successful changes appear without a manual page reload.
- Replace the temporary booking handler with this API-backed version:
async function submitBooking(event) {
event.preventDefault();
try {
await request('/api/appointments', {
method: 'POST',
body: JSON.stringify(booking),
});
setBooking((current) => ({ ...current, reason: '' }));
refresh('Appointment booked.');
} catch (error) {
setMessage(error.message);
}
}
What changed in booking?
Submitting the form now sends the selected patient, clinician, times, and reason to Express. A successful insert refreshes the persisted schedule.
A rejected overlap enters the catch block and displays the API message. The local array no longer gets a chance to create a duplicate.
- Save client/src/App.jsx.
- Return to the client terminal from the previous step.
- Confirm the Vite development server still shows its local URL.
- Return to ClinicFlow in Microsoft Edge.
- Set the schedule date to 2026-09-16.
- Confirm the table shows Maya Chen with Dr. Lena Ortiz from 09:00 to 09:30.
The seeded rows are now coming from PostgreSQL. Refreshing Microsoft Edge keeps them in place.
- Choose a fictional patient in the booking form.
- Select Dr. Lena Ortiz as the clinician.
- Set the start to 2026-09-16T09:00.
- Set the end to 2026-09-16T09:30.
- Enter Overlap test as the fictional reason.
Before you submit, do you think the request reaches the insert or gets stopped by the availability check?
- Click Confirm booking.
You should see That clinician already has an overlapping appointment. The table keeps one appointment for Dr. Lena Ortiz during that time range.
- Refresh Microsoft Edge.
- Confirm the rejected appointment remains absent from the daily table.
That is the gap closed. ClinicFlow now protects the schedule in the API and in PostgreSQL, even after the browser reloads.
Dashboard still shows temporary data?
Confirm the server terminal is running beside the existing client terminal. The Vite proxy needs the API listening on port 3000.
Check that the effect requests /api/data and that the booking handler requests /api/appointments.
Share the browser message and a redacted server error when requesting help. Help me trace why ClinicFlow is not loading PostgreSQL data.
✔️ Overlapping booking rejected
Excellent. The dashboard now reads a shared schedule and refuses to double-book Dr. Lena Ortiz.
- Keep both development servers running for the next workflow step.
ⓧ I'd like to double-check the client changes
useEffect(() => {
let active = true;
async function loadData() {
try {
const response = await fetch(
`/api/data?date=${encodeURIComponent(date)}&q=${encodeURIComponent(search)}`,
);
const body = await response.json();
if (!response.ok) throw new Error(body.error);
if (active) setData(body);
} catch (error) {
if (active) setMessage(error.message || 'Could not load ClinicFlow.');
}
}
loadData();
return () => {
active = false;
};
}, [date, search, refreshKey]);
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
'x-demo-role': role,
},
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Request failed.');
return body;
}
function refresh(successMessage) {
setMessage(successMessage);
setRefreshKey((value) => value + 1);
}
async function submitBooking(event) {
event.preventDefault();
try {
await request('/api/appointments', {
method: 'POST',
body: JSON.stringify(booking),
});
setBooking((current) => ({ ...current, reason: '' }));
refresh('Appointment booked.');
} catch (error) {
setMessage(error.message);
}
}
What should match?
Compare the data effect, request helper, refresh helper, and booking handler with these sections in client/src/App.jsx.
Your schedule is persistent and double-booking-safe. Next, you will connect patient creation, queue statuses, cancellation, and rescheduling to the same database-backed workflow.
Complete the Clinic Workflows
Your PostgreSQL schedule now stores appointments and rejects overlapping bookings. That gives ClinicFlow a safe foundation for the rest of the daily workflow.
Staff still need to create fictional patients and manage the queue without editing the database. In this step, you will connect those actions through React and Express so every change survives a browser refresh.
In this step, get ready to:
- Create fictional patients and find records through the dashboard search.
- Move appointments through the daily queue with persisted status controls.
- Reschedule appointments while preserving the original booking after a rejected conflict.
Create and search fictional patients
A scheduler depends on a patient directory that staff can update from the same screen. The API must validate each fictional record before PostgreSQL stores it.
- In server/server.js, locate the asyncRoute helper.
- Add the role middleware below that helper using this code:
function allowRoles(...allowedRoles) {
return (req, res, next) => {
const role = req.get('x-demo-role');
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: 'This demo role cannot perform that action.' });
}
next();
};
}
What does this middleware do?
- The middleware reads the demo role attached to a request.
- The allowed role list determines whether the request can continue.
- A rejected request ends with status 403 before it reaches a database query.
- In server/server.js, locate the route for POST /api/appointments.
- Add the patient creation route directly above it using this code:
app.post('/api/patients', allowRoles('Receptionist'), asyncRoute(async (req, res) => {
const fullName = String(req.body.fullName ?? '').trim();
const phone = String(req.body.phone ?? '').trim();
const dateOfBirth = String(req.body.dateOfBirth ?? '');
if (!fullName || !phone || !validDate(dateOfBirth)) {
return res.status(400).json({ error: 'Name, phone, and date of birth are required.' });
}
const existing = await pool.query('SELECT id FROM patients WHERE phone = $1', [phone]);
if (existing.rows.length) {
return res.status(409).json({ error: 'A fictional patient already uses that phone number.' });
}
const result = await pool.query(
`INSERT INTO patients (full_name, phone, date_of_birth)
VALUES ($1, $2, $3)
RETURNING id`,
[fullName, phone, dateOfBirth],
);
res.status(201).json(result.rows[0]);
}));
How does patient creation stay safe?
- The route rejects an empty name or phone number.
- The route uses validDate() to check the date of birth.
- The insert uses parameterized values so form input never becomes part of the SQL text.
- The phone lookup prevents two fictional patients from sharing the same phone number.
- In client/src/App.jsx, locate the request() function.
- Replace that function with this version:
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
'x-demo-role': role,
},
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Request failed.');
return body;
}
What does the request helper do?
- Every mutation sends JSON to the API.
- The x-demo-role header carries the selected demo role.
- An unsuccessful response becomes an error that the dashboard can display.
- In client/src/App.jsx, locate the refresh() function.
- Add the patient submission function below it using this code:
async function submitPatient(event) {
event.preventDefault();
try {
await request('/api/patients', {
method: 'POST',
body: JSON.stringify(patient),
});
setPatient(emptyPatient);
refresh('Fictional patient added.');
} catch (error) {
setMessage(error.message);
}
}
How does the form update the dashboard?
- The function sends the current patient state to the API.
- A successful response clears the form.
- The refresh() call reloads the saved PostgreSQL data.
- An API error becomes the visible dashboard message.
- In client/src/App.jsx, locate the existing booking form.
- Add this patient form beside the booking form:
<form className="card" onSubmit={submitPatient}>
<h2>Add fictional patient</h2>
<label>Full name<input required value={patient.fullName} onChange={(event) => setPatient({ ...patient, fullName: event.target.value })} /></label>
<label>Phone<input required value={patient.phone} onChange={(event) => setPatient({ ...patient, phone: event.target.value })} /></label>
<label>Date of birth<input required type="date" value={patient.dateOfBirth} onChange={(event) => setPatient({ ...patient, dateOfBirth: event.target.value })} /></label>
<button className="primary" type="submit">Add patient</button>
</form>
What does the patient form control?
- Each field updates one property in the patient state object.
- The form calls submitPatient() when the receptionist submits it.
- The existing search field filters the refreshed patient list by name or phone number.
- Save server/server.js.
- Save client/src/App.jsx.
- Switch back to the server terminal from the previous step.
- Press Ctrl+C to stop the current API process.
- Restart the API by running this command:
npm run dev
What should I see?
The terminal should report ClinicFlow API running at http://localhost:3000. The restarted process now includes the patient route.
- Return to ClinicFlow in Microsoft Edge.
- Select Receptionist as the demo role.
- Enter Jordan Lee in the Full name field.
- Enter 555-0199 in the Phone field.
- Enter 1990-01-15 in the Date of birth field.
- Click Add patient.
You should see Fictional patient added. above the dashboard. Jordan Lee should appear in the patient records.
- Enter Jordan in the patient and appointment search field.
You should see Jordan Lee in the filtered patient list. The saved record now comes back through GET /api/data.
Patient not appearing?
Confirm the API was restarted after you added the new route. Check that server/.env still points to the local clinic_demo database.
A repeated phone number is rejected because the patient table requires unique phone values.
Need help tracing the request?
Manage the daily queue
A daily schedule becomes useful when every queue change is stored in the database. ClinicFlow uses one status route so the table badges and summary counts reflect the latest saved state.
- In server/server.js, locate the port constant.
- Add these status lists below it:
const receptionistStatuses = ['Scheduled', 'Arrived', 'Cancelled'];
const clinicianStatuses = ['In Consultation', 'Completed'];
Why keep status lists on the server?
The lists define which queue changes each demo role can request. The API uses them before it updates an appointment.
- In server/server.js, locate the end of the appointment booking route.
- Add the status update route below it using this code:
app.patch(
'/api/appointments/:id/status',
allowRoles('Receptionist', 'Clinician'),
asyncRoute(async (req, res) => {
const role = req.get('x-demo-role');
const status = String(req.body.status ?? '');
const allowedStatuses = role === 'Receptionist'
? receptionistStatuses
: clinicianStatuses;
if (!allowedStatuses.includes(status)) {
return res.status(403).json({ error: `${role} cannot set status to ${status}.` });
}
const result = await pool.query(
`UPDATE appointments
SET status = $1
WHERE id = $2
RETURNING id`,
[status, Number(req.params.id)],
);
if (!result.rows.length) {
return res.status(404).json({ error: 'Appointment not found.' });
}
res.json(result.rows[0]);
}),
);
How does the queue update work?
- The appointment ID comes from the route parameter.
- The selected status comes from the JSON request body.
- The allowed list depends on the request's demo role.
- The parameterized update returns the changed appointment ID.
- In client/src/App.jsx, locate the submitBooking() function.
- Add the status update function below it using this code:
async function updateStatus(id, status) {
try {
await request(`/api/appointments/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
refresh(`Appointment marked ${status}.`);
} catch (error) {
setMessage(error.message);
}
}
How does a status button stay in sync?
- The function sends the appointment ID and requested status to the API.
- A successful update triggers a fresh data load.
- The refreshed appointment row changes its badge.
- The summary count changes when the new status belongs to a displayed metric.
- In the appointment table inside client/src/App.jsx, locate the cell with className="actions".
- Replace its contents with these queue controls:
<td className="actions">
{role === 'Receptionist' ? (
<>
<button onClick={() => updateStatus(appointment.id, 'Arrived')}>Arrived</button>
<button onClick={() => setReschedule({
id: appointment.id,
staffId: String(appointment.staffId),
startsAt: appointment.startsAt,
endsAt: appointment.endsAt,
})}>Reschedule</button>
<button className="danger" onClick={() => updateStatus(appointment.id, 'Cancelled')}>Cancel</button>
</>
) : (
<>
<button onClick={() => updateStatus(appointment.id, 'In Consultation')}>Start visit</button>
<button onClick={() => updateStatus(appointment.id, 'Completed')}>Complete</button>
</>
)}
</td>
What changes in the table?
- Receptionists can mark an appointment as arrived or cancelled.
- The reschedule button copies the selected appointment into editable state.
- Clinicians can move an appointment into consultation or complete it.
- Each saved value becomes a visible status badge after the refresh.
- Save server/server.js.
- Save client/src/App.jsx.
- Switch back to the server terminal.
- Press Ctrl+C to stop the API.
- Restart the updated API by running this command:
npm run dev
What should I see?
The API should start on port 3000 again. The status route is now ready to persist queue changes.
- Return to the dashboard in Microsoft Edge.
- Clear the search field.
- Keep Receptionist selected.
- Click Arrived on a scheduled appointment.
You should see an Arrived badge on that row. The Waiting summary should increase.
- Switch the demo role to Clinician.
- Click Start visit on the same appointment.
- Click Complete on that appointment.
You should see the badge move through In Consultation to Completed. The Completed summary should increase.
Status not changing?
Check that the selected demo role is permitted to set the requested status. A receptionist handles Arrived and Cancelled actions while a clinician handles In Consultation and Completed actions.
Confirm the browser request includes the x-demo-role header from the shared request() helper.
Help me debug a ClinicFlow status update.
Reschedule without losing the original booking
A rejected reschedule must leave the saved appointment untouched. The route checks for overlaps while excluding the appointment being moved.
- In server/server.js, locate the end of the status route.
- Add the first part of the rescheduling route below it:
app.patch(
'/api/appointments/:id/reschedule',
allowRoles('Receptionist'),
asyncRoute(async (req, res) => {
const appointmentId = Number(req.params.id);
const staffId = Number(req.body.staffId);
const startsAt = String(req.body.startsAt ?? '');
const endsAt = String(req.body.endsAt ?? '');
if (!appointmentId || !staffId || !validWindow(startsAt, endsAt)) {
return res.status(400).json({ error: 'Choose a valid clinician and time range.' });
}
const overlap = await findOverlap(staffId, startsAt, endsAt, appointmentId);
if (overlap.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
What does the first route section check?
- The route reads the selected appointment and replacement time range.
- The validWindow() helper rejects incomplete or reversed times.
- The appointment ID is passed to findOverlap() so the selected row does not conflict with itself.
- Continue the same route immediately after the overlap check with this code:
try {
const result = await pool.query(
`UPDATE appointments
SET staff_id = $1,
starts_at = $2,
ends_at = $3,
status = 'Scheduled'
WHERE id = $4
RETURNING id`,
[staffId, startsAt, endsAt, appointmentId],
);
if (!result.rows.length) {
return res.status(404).json({ error: 'Appointment not found.' });
}
res.json(result.rows[0]);
} catch {
res.status(409).json({ error: 'The database rejected that overlapping time range.' });
}
}),
);
Why does the update have another guard?
- The update writes the new clinician and time range as one database operation.
- A successful reschedule returns the appointment ID.
- The catch block handles a conflict rejected by the database constraint.
- A rejected update leaves the original row unchanged.
- In client/src/App.jsx, locate the existing state declarations.
- Add the rescheduling state below the booking state:
const [reschedule, setReschedule] = useState(null);
What does this state hold?
The reschedule value holds the selected appointment ID and proposed replacement fields. A null value keeps the rescheduling form closed.
- In client/src/App.jsx, locate the updateStatus() function.
- Add the rescheduling submission function below it using this code:
async function submitReschedule(event) {
event.preventDefault();
try {
await request(`/api/appointments/${reschedule.id}/reschedule`, {
method: 'PATCH',
body: JSON.stringify(reschedule),
});
setReschedule(null);
refresh('Appointment rescheduled.');
} catch (error) {
setMessage(error.message);
}
}
How does rescheduling preserve the form state?
- The function sends the selected appointment and proposed time range to the API.
- A successful update closes the form.
- A rejected update keeps the form open so the receptionist can correct the time.
- The refreshed table reveals the saved result.
- In client/src/App.jsx, locate the end of the receptionist forms section.
- Add this rescheduling form below that section:
{role === 'Receptionist' && reschedule && (
<form className="card" onSubmit={submitReschedule}>
<h2>Reschedule appointment #{reschedule.id}</h2>
<div className="form-grid">
<label>Clinician
<select required value={reschedule.staffId} onChange={(event) => setReschedule({ ...reschedule, staffId: event.target.value })}>
{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}
</select>
</label>
<label>Starts<input required type="datetime-local" value={reschedule.startsAt} onChange={(event) => setReschedule({ ...reschedule, startsAt: event.target.value })} /></label>
<label>Ends<input required type="datetime-local" value={reschedule.endsAt} onChange={(event) => setReschedule({ ...reschedule, endsAt: event.target.value })} /></label>
</div>
<div className="actions">
<button className="primary" type="submit">Save new time</button>
<button type="button" onClick={() => setReschedule(null)}>Close</button>
</div>
</form>
)}
What does the rescheduling form show?
- The form opens only for a receptionist with a selected appointment.
- The existing clinician and time values provide the starting point.
- Saving submits the proposed range through PATCH /api/appointments/:id/reschedule.
- Closing clears the selected appointment without changing PostgreSQL.
- Save server/server.js.
- Save client/src/App.jsx.
- Switch back to the server terminal.
- Press Ctrl+C to stop the API.
- Restart the completed workflow API by running this command:
npm run dev
What should I see?
The terminal should confirm that ClinicFlow is running on port 3000. The dashboard can now create patients and persist every appointment workflow.
Before you test the conflict path, do you expect the selected appointment to move when its proposed time overlaps an existing appointment?
- Return to ClinicFlow in Microsoft Edge.
- Select Receptionist as the demo role.
- Set the schedule date to 2026-09-16.
- Click Reschedule on an appointment outside Dr. Lena Ortiz's seeded slot.
- Select Dr. Lena Ortiz in the rescheduling form.
- Set Starts to 2026-09-16T09:00.
- Set Ends to 2026-09-16T09:30.
- Click Save new time.
You should see That clinician already has an overlapping appointment. The appointment should retain its original clinician and time.
- Change Starts to 2026-09-16T11:30.
- Change Ends to 2026-09-16T12:00.
- Click Save new time.
You should see Appointment rescheduled. The table should show the new open time.
Before the final workflow check, which changes do you expect to remain after the browser reloads?
- Create another fictional patient with a unique phone number.
- Find that patient with the search field.
- Book the patient into an open clinician slot.
- Reschedule the new appointment to another open slot.
- Click Arrived on the new appointment.
- Refresh the browser.
You should still see the fictional patient and rescheduled appointment. The appointment should retain its Arrived badge.
- Click Cancel on the appointment.
- Refresh the browser again.
You should see the appointment with a Cancelled badge after the refresh. Your patient creation and search results should also remain available.
Reschedule not behaving as expected?
Check that findOverlap() receives appointmentId as its fourth argument. That value excludes the appointment being moved.
Confirm that the update runs only after the overlap query returns no rows. The database constraint remains the final guard if another request claims the slot first.
Help me debug ClinicFlow rescheduling.
That is the full clinic workflow working from one dashboard. Next, you will prove that the demo role restrictions hold when a request bypasses the visible controls and save ClinicFlow in Git.
Enforce Demo Roles and Save the Project
Your PostgreSQL-backed dashboard now handles the clinic workflow from patient creation through rescheduling. Each successful change survives a browser refresh.
A hidden React control only changes what someone sees. The Express API must also reject requests that the selected role cannot perform.
In this step, get ready to:
- Send the selected demo role with every protected mutation request.
- Enforce Receptionist and Clinician permissions in the API and dashboard.
- Commit the project with Git and publish it to GitHub without the local database password.
Send the selected demo role
The role selector gives each request a clear demo identity. You will first connect that identity without changing the API rules.
- In client/src/App.jsx, find the state declarations at the start of App().
- Add this role state above the existing date state:
const [role, setRole] = useState('Receptionist');
What does this state hold?
The role value records the selected demo role. Calling setRole updates the dashboard whenever the learner switches roles.
- In the returned JSX, replace the existing hero header with this role-aware header.
- Place the learning-only notice immediately below the header:
<header className="hero">
<div>
<p className="eyebrow">Fictional data only</p>
<h1>ClinicFlow</h1>
<p>Daily appointments, patient lookup, and a safer booking workflow.</p>
</div>
<label className="role-picker">
Demo role
<select value={role} onChange={(event) => setRole(event.target.value)}>
<option>Receptionist</option>
<option>Clinician</option>
</select>
</label>
</header>
<p className="notice">
This learning prototype is not production authentication and must not contain real patient data.
</p>
What does this interface show?
- The role picker keeps Receptionist and Clinician available as visible demo identities.
- The notice explains the security limit of the simulation. It also keeps real patient information out of the project.
- Save client/src/App.jsx.
- Return to ClinicFlow in Microsoft Edge and refresh the page.
You should see the demo role selector in the header. You should also see the learning-only notice below it.
Role selector missing?
Check that the new state line sits inside App(). Confirm that the replacement header remains inside the returned main element.
Ask for help if the page becomes blank. Help me find a JSX or state placement error in my ClinicFlow role selector.
The selected role must now travel with protected requests. The shared request helper is the single place where that header belongs.
- In client/src/App.jsx, find the existing request function.
- Replace that function with this role-aware version:
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
'x-demo-role': role,
},
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Request failed.');
return body;
}
What does this request helper do?
- The headers object sends JSON metadata with every mutation.
- The x-demo-role header carries the currently selected role to Express.
- The existing response check turns rejected API responses into visible dashboard messages.
- Save client/src/App.jsx.
- Select Clinician from the demo role selector.
- Enter Avery Test in the Full name field.
- Enter 555-0199 in the Phone field.
- Enter 1990-01-01 in the Date of birth field.
Before you submit the form, do you think the API currently checks the new role header?
- Click Add patient.
You will see the fictional patient added even though Clinician is selected. This successful unauthorized action is the intended gap.
Why did the request succeed?
The browser now sends a role header. The API still accepts the request because no route checks that value.
Interface state provides context for authorization. Server middleware provides the actual enforcement.
Enforce and reflect the permissions
Express middleware can stop a request before its route changes the database. ClinicFlow will use one reusable guard for every protected route.
- In server/server.js, locate the asyncRoute helper.
- Add this role middleware immediately below it:
function allowRoles(...allowedRoles) {
return (req, res, next) => {
const role = req.get('x-demo-role');
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: 'This demo role cannot perform that action.' });
}
next();
};
}
How does the guard work?
- The allowedRoles rest parameter lets each route declare its permitted roles.
- The middleware reads x-demo-role from the request.
- A disallowed role receives status 403 before the route handler reaches PostgreSQL.
- Find the route declaration for POST /api/patients.
- Add allowRoles('Receptionist') between the route path and asyncRoute.
- Save server/server.js.
- Return to the server terminal from earlier and press Ctrl+C to stop the running API.
- Press the Up Arrow key to recall the previous server command.
- Press Enter to restart the API.
Before you resubmit the patient form, do you expect the same Clinician request to reach the database?
- Return to ClinicFlow with Clinician still selected.
- Submit the fictional patient form again.
You will now see This demo role cannot perform that action. The second record is not created.
Patient request still succeeds?
Confirm that allowRoles('Receptionist') is inside the POST /api/patients route declaration. Restart the server after saving because the current development script does not watch for file changes.
Ask for help with the middleware placement. Help me debug why my Clinician patient request still succeeds.
- Add allowRoles('Receptionist') to the POST /api/appointments route.
- Add allowRoles('Receptionist') to the PATCH /api/appointments/:id/reschedule route.
- Save server/server.js.
- Restart the API from the server terminal using the same stop and recall sequence.
- Submit the booking form while Clinician is selected.
You should see the same status 403 message. No appointment is added.
Status changes need finer rules because both roles can update an appointment. Each role receives a separate list of allowed status values.
- In server/server.js, replace the existing status route with this role-aware route:
app.patch(
'/api/appointments/:id/status',
allowRoles('Receptionist', 'Clinician'),
asyncRoute(async (req, res) => {
const role = req.get('x-demo-role');
const status = String(req.body.status ?? '');
const allowedStatuses = role === 'Receptionist'
? receptionistStatuses
: clinicianStatuses;
if (!allowedStatuses.includes(status)) {
return res.status(403).json({ error: `${role} cannot set status to ${status}.` });
}
const result = await pool.query(
`UPDATE appointments
SET status = $1
WHERE id = $2
RETURNING id`,
[status, Number(req.params.id)],
);
if (!result.rows.length) {
return res.status(404).json({ error: 'Appointment not found.' });
}
res.json(result.rows[0]);
}),
);
How are status permissions divided?
- Both roles can reach the status route because both appear in allowRoles.
- Receptionists are limited to receptionistStatuses.
- Clinicians are limited to clinicianStatuses.
- A status outside the selected list receives status 403 before the update query runs.
- Save server/server.js.
- Restart the API from the server terminal.
- Return to ClinicFlow and keep Clinician selected.
- Use an existing status control to mark an appointment In Consultation.
You should see the appointment change to In Consultation. The successful update proves the Clinician role still has its permitted workflow.
Status update rejected?
Check that clinicianStatuses contains In Consultation and Completed. Confirm that the request helper sends the selected role.
Ask for help with the role-specific status check. Help me debug a valid Clinician status update that returns 403.
✔️ Awesome, I've got everything!
Great. Save server/server.js before continuing.
ⓧ I'd like to double check the full code
import { loadEnvFile } from 'node:process';
import express from 'express';
import pg from 'pg';
loadEnvFile();
const { Pool } = pg;
const app = express();
const pool = new Pool();
const port = Number(process.env.PORT ?? 3000);
const receptionistStatuses = ['Scheduled', 'Arrived', 'Cancelled'];
const clinicianStatuses = ['In Consultation', 'Completed'];
app.use(express.json());
const asyncRoute = (handler) => (req, res, next) =>
Promise.resolve(handler(req, res, next)).catch(next);
function allowRoles(...allowedRoles) {
return (req, res, next) => {
const role = req.get('x-demo-role');
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: 'This demo role cannot perform that action.' });
}
next();
};
}
function validDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function validWindow(startsAt, endsAt) {
const start = Date.parse(startsAt);
const end = Date.parse(endsAt);
return Number.isFinite(start) && Number.isFinite(end) && end > start;
}
async function findOverlap(staffId, startsAt, endsAt, ignoredId = 0) {
return pool.query(
`SELECT id
FROM appointments
WHERE staff_id = $1
AND id <> $4
AND status <> 'Cancelled'
AND tsrange(starts_at, ends_at, '[)') &&
tsrange($2::timestamp, $3::timestamp, '[)')
LIMIT 1`,
[staffId, startsAt, endsAt, ignoredId],
);
}
app.get('/api/health', asyncRoute(async (_req, res) => {
const result = await pool.query('SELECT 1 AS ok');
res.json({ ok: result.rows[0].ok === 1 });
}));
app.get('/api/data', asyncRoute(async (req, res) => {
const date = String(req.query.date ?? '');
const search = `%${String(req.query.q ?? '').trim()}%`;
if (!validDate(date)) {
return res.status(400).json({ error: 'Use a date in YYYY-MM-DD format.' });
}
const [patientsResult, staffResult, appointmentsResult] = await Promise.all([
pool.query(
`SELECT id,
full_name AS "fullName",
phone,
to_char(date_of_birth, 'YYYY-MM-DD') AS "dateOfBirth"
FROM patients
ORDER BY full_name`,
),
pool.query(
`SELECT id, full_name AS "fullName"
FROM staff
WHERE role = 'Clinician'
ORDER BY full_name`,
),
pool.query(
`SELECT a.id,
a.patient_id AS "patientId",
a.staff_id AS "staffId",
p.full_name AS "patientName",
p.phone,
s.full_name AS "staffName",
to_char(a.starts_at, 'YYYY-MM-DD"T"HH24:MI') AS "startsAt",
to_char(a.ends_at, 'YYYY-MM-DD"T"HH24:MI') AS "endsAt",
to_char(a.starts_at, 'HH24:MI') AS "startTime",
to_char(a.ends_at, 'HH24:MI') AS "endTime",
a.status,
a.reason
FROM appointments a
JOIN patients p ON p.id = a.patient_id
JOIN staff s ON s.id = a.staff_id
WHERE a.starts_at::date = $1::date
AND (
p.full_name ILIKE $2 OR
p.phone ILIKE $2 OR
s.full_name ILIKE $2 OR
a.reason ILIKE $2
)
ORDER BY a.starts_at`,
[date, search],
),
]);
res.json({
patients: patientsResult.rows,
staff: staffResult.rows,
appointments: appointmentsResult.rows,
});
}));
app.post('/api/patients', allowRoles('Receptionist'), asyncRoute(async (req, res) => {
const fullName = String(req.body.fullName ?? '').trim();
const phone = String(req.body.phone ?? '').trim();
const dateOfBirth = String(req.body.dateOfBirth ?? '');
if (!fullName || !phone || !validDate(dateOfBirth)) {
return res.status(400).json({ error: 'Name, phone, and date of birth are required.' });
}
const existing = await pool.query('SELECT id FROM patients WHERE phone = $1', [phone]);
if (existing.rows.length) {
return res.status(409).json({ error: 'A fictional patient already uses that phone number.' });
}
const result = await pool.query(
`INSERT INTO patients (full_name, phone, date_of_birth)
VALUES ($1, $2, $3)
RETURNING id`,
[fullName, phone, dateOfBirth],
);
res.status(201).json(result.rows[0]);
}));
app.post('/api/appointments', allowRoles('Receptionist'), asyncRoute(async (req, res) => {
const patientId = Number(req.body.patientId);
const staffId = Number(req.body.staffId);
const startsAt = String(req.body.startsAt ?? '');
const endsAt = String(req.body.endsAt ?? '');
const reason = String(req.body.reason ?? '').trim();
if (!patientId || !staffId || !reason || !validWindow(startsAt, endsAt)) {
return res.status(400).json({ error: 'Complete every booking field with a valid time range.' });
}
const overlap = await findOverlap(staffId, startsAt, endsAt);
if (overlap.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
const result = await pool.query(
`INSERT INTO appointments
(patient_id, staff_id, starts_at, ends_at, status, reason)
VALUES ($1, $2, $3, $4, 'Scheduled', $5)
ON CONFLICT DO NOTHING
RETURNING id`,
[patientId, staffId, startsAt, endsAt, reason],
);
if (!result.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
res.status(201).json(result.rows[0]);
}));
app.patch(
'/api/appointments/:id/status',
allowRoles('Receptionist', 'Clinician'),
asyncRoute(async (req, res) => {
const role = req.get('x-demo-role');
const status = String(req.body.status ?? '');
const allowedStatuses = role === 'Receptionist'
? receptionistStatuses
: clinicianStatuses;
if (!allowedStatuses.includes(status)) {
return res.status(403).json({ error: `${role} cannot set status to ${status}.` });
}
const result = await pool.query(
`UPDATE appointments
SET status = $1
WHERE id = $2
RETURNING id`,
[status, Number(req.params.id)],
);
if (!result.rows.length) {
return res.status(404).json({ error: 'Appointment not found.' });
}
res.json(result.rows[0]);
}),
);
app.patch(
'/api/appointments/:id/reschedule',
allowRoles('Receptionist'),
asyncRoute(async (req, res) => {
const appointmentId = Number(req.params.id);
const staffId = Number(req.body.staffId);
const startsAt = String(req.body.startsAt ?? '');
const endsAt = String(req.body.endsAt ?? '');
if (!appointmentId || !staffId || !validWindow(startsAt, endsAt)) {
return res.status(400).json({ error: 'Choose a valid clinician and time range.' });
}
const overlap = await findOverlap(staffId, startsAt, endsAt, appointmentId);
if (overlap.rows.length) {
return res.status(409).json({ error: 'That clinician already has an overlapping appointment.' });
}
try {
const result = await pool.query(
`UPDATE appointments
SET staff_id = $1,
starts_at = $2,
ends_at = $3,
status = 'Scheduled'
WHERE id = $4
RETURNING id`,
[staffId, startsAt, endsAt, appointmentId],
);
if (!result.rows.length) {
return res.status(404).json({ error: 'Appointment not found.' });
}
res.json(result.rows[0]);
} catch {
res.status(409).json({ error: 'The database rejected that overlapping time range.' });
}
}),
);
app.use((error, _req, res, _next) => {
console.error(error);
res.status(500).json({ error: 'Unexpected server error.' });
});
app.listen(port, () => {
console.log(`ClinicFlow API running at http://localhost:${port}`);
});
The server now protects the data. The dashboard should also show only the controls that make sense for the selected role.
- In client/src/App.jsx, find the appointment action cell inside the daily schedule table.
- Replace its contents with this role-specific action group:
<td className="actions">
{role === 'Receptionist' ? (
<>
<button onClick={() => updateStatus(appointment.id, 'Arrived')}>Arrived</button>
<button onClick={() => setReschedule({
id: appointment.id,
staffId: String(appointment.staffId),
startsAt: appointment.startsAt,
endsAt: appointment.endsAt,
})}>Reschedule</button>
<button className="danger" onClick={() => updateStatus(appointment.id, 'Cancelled')}>Cancel</button>
</>
) : (
<>
<button onClick={() => updateStatus(appointment.id, 'In Consultation')}>Start visit</button>
<button onClick={() => updateStatus(appointment.id, 'Completed')}>Complete</button>
</>
)}
</td>
What changes with the role?
Receptionists receive arrival, rescheduling, and cancellation controls. Clinicians receive the two clinical progress controls.
These conditional controls guide the user toward permitted actions. The API middleware remains the enforcement layer.
- Wrap the existing booking and patient forms in the role === 'Receptionist' branch.
- Use the existing two-column section as that branch without changing either form.
- Add this card as the Clinician branch:
<section className="card"><p>Clinicians can update clinical statuses but cannot create patients, book, reschedule, or cancel appointments.</p></section>
Why show a replacement card?
The card states the Clinician permissions where the Receptionist forms normally appear. The role change stays understandable without exposing unusable controls.
- Save client/src/App.jsx.
- Refresh ClinicFlow in Microsoft Edge.
- Switch to Receptionist and confirm that booking, patient creation, rescheduling, arrival, and cancellation controls are visible.
- Switch to Clinician and confirm that only Start visit and Complete remain in the appointment table.
The dashboard now changes immediately with the selected role. The Clinician card explains why the other workflow controls are absent.
- Find the condition that renders the existing rescheduling form.
- Add the Receptionist role check so the condition reads role === 'Receptionist' && reschedule.
- Save client/src/App.jsx.
- Select Receptionist and open a rescheduling form.
- Switch to Clinician and confirm that the open rescheduling form disappears.
That completes the visible permission boundary. The server independently rejects the same disallowed actions with status 403.
Seeing the wrong controls?
Check each comparison uses the exact values Receptionist and Clinician. Confirm that the form condition also checks the current role.
Ask for help with the JSX branches. Help me debug why ClinicFlow shows controls for the wrong demo role.
✔️ Awesome, I've got everything!
Excellent. Save client/src/App.jsx before creating the repository.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react';
const DEMO_DATE = '2026-09-16';
const emptyPatient = { fullName: '', phone: '', dateOfBirth: '' };
const emptyBooking = {
patientId: '',
staffId: '',
startsAt: `${DEMO_DATE}T09:00`,
endsAt: `${DEMO_DATE}T09:30`,
reason: '',
};
export default function App() {
const [role, setRole] = useState('Receptionist');
const [date, setDate] = useState(DEMO_DATE);
const [search, setSearch] = useState('');
const [data, setData] = useState({ patients: [], staff: [], appointments: [] });
const [patient, setPatient] = useState(emptyPatient);
const [booking, setBooking] = useState(emptyBooking);
const [reschedule, setReschedule] = useState(null);
const [message, setMessage] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
let active = true;
async function loadData() {
try {
const response = await fetch(
`/api/data?date=${encodeURIComponent(date)}&q=${encodeURIComponent(search)}`,
);
const body = await response.json();
if (!response.ok) throw new Error(body.error);
if (active) setData(body);
} catch (error) {
if (active) setMessage(error.message || 'Could not load ClinicFlow.');
}
}
loadData();
return () => {
active = false;
};
}, [date, search, refreshKey]);
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
'x-demo-role': role,
},
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Request failed.');
return body;
}
function refresh(successMessage) {
setMessage(successMessage);
setRefreshKey((value) => value + 1);
}
async function submitPatient(event) {
event.preventDefault();
try {
await request('/api/patients', {
method: 'POST',
body: JSON.stringify(patient),
});
setPatient(emptyPatient);
refresh('Fictional patient added.');
} catch (error) {
setMessage(error.message);
}
}
async function submitBooking(event) {
event.preventDefault();
try {
await request('/api/appointments', {
method: 'POST',
body: JSON.stringify(booking),
});
setBooking((current) => ({ ...current, reason: '' }));
refresh('Appointment booked.');
} catch (error) {
setMessage(error.message);
}
}
async function updateStatus(id, status) {
try {
await request(`/api/appointments/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
refresh(`Appointment marked ${status}.`);
} catch (error) {
setMessage(error.message);
}
}
async function submitReschedule(event) {
event.preventDefault();
try {
await request(`/api/appointments/${reschedule.id}/reschedule`, {
method: 'PATCH',
body: JSON.stringify(reschedule),
});
setReschedule(null);
refresh('Appointment rescheduled.');
} catch (error) {
setMessage(error.message);
}
}
const patientMatches = data.patients.filter((item) => {
const term = search.toLowerCase();
return item.fullName.toLowerCase().includes(term) || item.phone.includes(term);
});
const scheduled = data.appointments.filter((item) => item.status === 'Scheduled').length;
const waiting = data.appointments.filter((item) => item.status === 'Arrived').length;
const completed = data.appointments.filter((item) => item.status === 'Completed').length;
return (
<main className="shell">
<header className="hero">
<div>
<p className="eyebrow">Fictional data only</p>
<h1>ClinicFlow</h1>
<p>Daily appointments, patient lookup, and a safer booking workflow.</p>
</div>
<label className="role-picker">
Demo role
<select value={role} onChange={(event) => setRole(event.target.value)}>
<option>Receptionist</option>
<option>Clinician</option>
</select>
</label>
</header>
<p className="notice">
This learning prototype is not production authentication and must not contain real patient data.
</p>
{message && <p className="message" aria-live="polite">{message}</p>}
<section className="toolbar card">
<label>
Schedule date
<input
type="date"
value={date}
onChange={(event) => {
const nextDate = event.target.value;
setDate(nextDate);
setBooking((current) => ({
...current,
startsAt: `${nextDate}T09:00`,
endsAt: `${nextDate}T09:30`,
}));
}}
/>
</label>
<label>
Search patients or appointments
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Name, phone, clinician, or reason"
/>
</label>
</section>
<section className="metrics">
<article className="card"><strong>{data.appointments.length}</strong><span>Total today</span></article>
<article className="card"><strong>{scheduled}</strong><span>Scheduled</span></article>
<article className="card"><strong>{waiting}</strong><span>Waiting</span></article>
<article className="card"><strong>{completed}</strong><span>Completed</span></article>
</section>
<section className="card">
<h2>Daily staff calendar</h2>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Time</th><th>Patient</th><th>Clinician</th><th>Reason</th><th>Status</th><th>Actions</th>
</tr>
</thead>
<tbody>
{data.appointments.map((appointment) => (
<tr key={appointment.id}>
<td>{appointment.startTime} to {appointment.endTime}</td>
<td>{appointment.patientName}<small>{appointment.phone}</small></td>
<td>{appointment.staffName}</td>
<td>{appointment.reason}</td>
<td><span className={`badge ${appointment.status.toLowerCase().replaceAll(' ', '-')}`}>{appointment.status}</span></td>
<td className="actions">
{role === 'Receptionist' ? (
<>
<button onClick={() => updateStatus(appointment.id, 'Arrived')}>Arrived</button>
<button onClick={() => setReschedule({
id: appointment.id,
staffId: String(appointment.staffId),
startsAt: appointment.startsAt,
endsAt: appointment.endsAt,
})}>Reschedule</button>
<button className="danger" onClick={() => updateStatus(appointment.id, 'Cancelled')}>Cancel</button>
</>
) : (
<>
<button onClick={() => updateStatus(appointment.id, 'In Consultation')}>Start visit</button>
<button onClick={() => updateStatus(appointment.id, 'Completed')}>Complete</button>
</>
)}
</td>
</tr>
))}
{!data.appointments.length && (
<tr><td colSpan="6">No matching appointments.</td></tr>
)}
</tbody>
</table>
</div>
</section>
{role === 'Receptionist' ? (
<section className="two-column">
<form className="card" onSubmit={submitBooking}>
<h2>Book appointment</h2>
<label>Patient
<select required value={booking.patientId} onChange={(event) => setBooking({ ...booking, patientId: event.target.value })}>
<option value="">Choose a patient</option>
{data.patients.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}
</select>
</label>
<label>Clinician
<select required value={booking.staffId} onChange={(event) => setBooking({ ...booking, staffId: event.target.value })}>
<option value="">Choose a clinician</option>
{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}
</select>
</label>
<label>Starts<input required type="datetime-local" value={booking.startsAt} onChange={(event) => setBooking({ ...booking, startsAt: event.target.value })} /></label>
<label>Ends<input required type="datetime-local" value={booking.endsAt} onChange={(event) => setBooking({ ...booking, endsAt: event.target.value })} /></label>
<label>Reason<input required value={booking.reason} onChange={(event) => setBooking({ ...booking, reason: event.target.value })} /></label>
<button className="primary" type="submit">Confirm booking</button>
</form>
<form className="card" onSubmit={submitPatient}>
<h2>Add fictional patient</h2>
<label>Full name<input required value={patient.fullName} onChange={(event) => setPatient({ ...patient, fullName: event.target.value })} /></label>
<label>Phone<input required value={patient.phone} onChange={(event) => setPatient({ ...patient, phone: event.target.value })} /></label>
<label>Date of birth<input required type="date" value={patient.dateOfBirth} onChange={(event) => setPatient({ ...patient, dateOfBirth: event.target.value })} /></label>
<button className="primary" type="submit">Add patient</button>
</form>
</section>
) : (
<section className="card"><p>Clinicians can update clinical statuses but cannot create patients, book, reschedule, or cancel appointments.</p></section>
)}
{role === 'Receptionist' && reschedule && (
<form className="card" onSubmit={submitReschedule}>
<h2>Reschedule appointment #{reschedule.id}</h2>
<div className="form-grid">
<label>Clinician
<select required value={reschedule.staffId} onChange={(event) => setReschedule({ ...reschedule, staffId: event.target.value })}>
{data.staff.map((item) => <option key={item.id} value={item.id}>{item.fullName}</option>)}
</select>
</label>
<label>Starts<input required type="datetime-local" value={reschedule.startsAt} onChange={(event) => setReschedule({ ...reschedule, startsAt: event.target.value })} /></label>
<label>Ends<input required type="datetime-local" value={reschedule.endsAt} onChange={(event) => setReschedule({ ...reschedule, endsAt: event.target.value })} /></label>
</div>
<div className="actions">
<button className="primary" type="submit">Save new time</button>
<button type="button" onClick={() => setReschedule(null)}>Close</button>
</div>
</form>
)}
<section className="card">
<h2>Patient records</h2>
<div className="patient-list">
{patientMatches.map((item) => (
<article key={item.id}>
<strong>{item.fullName}</strong>
<span>{item.phone}</span>
<span>DOB: {item.dateOfBirth}</span>
</article>
))}
{!patientMatches.length && <p>No matching fictional patients.</p>}
</div>
</section>
</main>
);
}
Commit and publish the project
A repository records the finished source files as a reviewable snapshot. The ignore file keeps local dependencies and the database password outside that snapshot.
- Return to the .gitignore file in the clinicflow workspace.
- Confirm that its complete contents match this reference:
node_modules/
dist/
.env
What stays out of the repository?
- The node_modules/ rule excludes installed dependency folders.
- The dist/ rule excludes generated production builds.
- The .env rule excludes server/.env and its local PostgreSQL password.
Publishing sends the committed files to your GitHub account. Your ignored server/.env remains on your computer when it is absent from Source Control.
- Select Source Control in the Visual Studio Code Activity Bar.
- Select Initialize Repository for the open clinicflow folder.
- Inspect the Changes list for the project files.
- Confirm that server/.env, client/node_modules, server/node_modules, and dist are absent.
- Select Stage All Changes from the Changes header.
- Enter Build ClinicFlow appointment dashboard in the commit message box.
- Select Commit.
Well done. Your ClinicFlow source is now recorded in a local Git commit while the database password stays outside its history.
- Select Publish to GitHub in the Source Control view.
- Continue with the signed-in GitHub account from setup if Visual Studio Code requests authorization.
- Choose whether the ClinicFlow repository is public or private.
- Review the file selection and confirm that server/.env is absent.
- Complete the publishing prompt.
Visual Studio Code creates the GitHub repository, adds it as the remote, and pushes the committed project.
Publishing or committing failed?
If no files appear after initialization, confirm that Visual Studio Code still has the clinicflow folder open. If publishing requests authentication, complete the GitHub sign-in prompt with the account from setup.
Do not continue if server/.env appears in the staged file list. Remove it from staging and confirm the .env rule is saved first.
Ask for help with the repository state. Help me publish ClinicFlow from Visual Studio Code without including server/.env.
Before the final check, which controls do you expect each role to see after the repository is published?
- Return to ClinicFlow in Microsoft Edge.
- Select Receptionist and confirm that patient creation, booking, arrival, rescheduling, and cancellation controls are visible.
- Select Clinician and confirm that only Start visit and Complete remain as mutation controls.
- Return to Visual Studio Code and confirm that Source Control has no uncommitted project changes.
- Open the published repository in your GitHub account and confirm that server/.env is absent.
ClinicFlow now demonstrates separate Receptionist and Clinician workflows in the interface. Express enforces the same permissions when a request reaches the API directly.
Secret mission
Stress-Test the Booking Rules
Race two overlapping bookings to test ClinicFlow under competing requests. Then bypass the visible controls to prove that the server still blocks an unauthorized demo role.
Clean Up Your Resources
Clean Up Your Resources
ClinicFlow uses local software plus GitHub Free, so the documented path has no paid resources. Choose whether to keep the app running, pause its development servers, or remove everything.
Resources you used:
- Your local clinic_demo database in PostgreSQL.
- Your local clinicflow source folder.
- Your client/node_modules dependency folder.
- Your server/node_modules dependency folder.
- Your ignored server/.env password file.
- Your local Git repository inside clinicflow.
- Your published ClinicFlow repository on GitHub.
Keep everything running
No action needed. Choose this if you are still testing ClinicFlow or plan to extend its workflows.
- Leave both Visual Studio Code development terminals running.
- Keep the clinic_demo database for your fictional schedule.
- Keep the local clinicflow folder on your computer.
- Keep the published ClinicFlow repository in your GitHub account.
Pause - I'll come back to this later
Shut down the development servers to free their ports. Your database and project files remain available for your return.
- Press Ctrl+C in the client development terminal from earlier.
- Press Ctrl+C in the server development terminal from earlier.
- Keep the clinic_demo database in PostgreSQL.
- Keep the local clinicflow folder on your computer.
- Keep the published ClinicFlow repository in your GitHub account.
ClinicFlow stops serving locally. Your persisted fictional records remain ready for the next session.
Delete - I don't want to use this again
Deletion is permanent, so use this option only when you no longer need ClinicFlow. Your ignored password file disappears with the local project folder without being published.
- Press Ctrl+C in the client development terminal from earlier.
- Press Ctrl+C in the server development terminal from earlier.
- Use pgAdmin 4 to disconnect from clinic_demo.
- Delete clinic_demo from the pgAdmin 4 database tree.
- Delete the local clinicflow folder in File Explorer.
- Return to your published ClinicFlow repository on GitHub.
- Use the repository deletion controls in its settings to delete it separately.
- Complete GitHub's repository deletion confirmation.
Removing clinicflow also removes client/node_modules plus server/node_modules.
It also removes the local Git repository.
It also removes server/.env from your computer.
Nice Work!
Nice Work!
You did it! ClinicFlow now manages a fictional daily schedule while protecting each clinician from overlapping bookings.
What you learned:
- Built a React daily dashboard that manages fictional clinic appointments. Added patient search. Made each queue status visible.
- Connected an Express API to PostgreSQL with parameterized queries. Made every clinic workflow persist after a browser refresh. Blocked overlapping clinician times with an application check plus a database exclusion constraint.
- Enforced demo role permissions through Express middleware. Kept server/.env out of Git. Published the committed repository to GitHub.
- Secret Mission: Stress-tested competing bookings in two Microsoft Edge windows. Confirmed that only one overlapping appointment was saved. Verified that a Clinician request received status 403 for a Receptionist-only action.
Ready to quiz yourself?