Build a Timesheet Portal with Next.js
Build a full-stack accessible timesheet portal with role-based auth.
Introduction
30 Second Summary
Care workers finish long shifts and then have to wrestle with clunky paper timesheets or confusing digital systems. A well-designed portal can make that end-of-shift task feel effortless.
In this project, you will build a complete digital timesheet management portal for a care organisation with 5200+ staff. You will implement accessible design, role-based authentication, and a multi-step wizard form that guides care workers through submitting their hours.
What You'll Build
A production-grade, 8-page timesheet portal built with Next.js 16, Tailwind CSS v4, and Auth.js v5, deployed on Vercel.
By the end of this project, you'll have:
- A fully functional timesheet portal with a 10-step wizard, overtime form, annual leave booking, and sickness reporting.
- A complete WCAG 2.1 AA accessible design system with a persistent accessibility toolbar for font scaling, high contrast mode, and text-to-speech.
- Role-based authentication protecting care worker and line manager routes with middleware route protection.
- Secret Mission: Add real-time push notifications connecting care workers and line managers in a responsive feedback loop.
Are there any prerequisites?
You should be comfortable with HTML, CSS, and JavaScript. Basic React knowledge is helpful but not required.
Set Up the Project
Before writing any code, you need the right tools installed and a working project scaffold. This step gets your development environment ready so every future step can focus on building features.
You will install Node.js and Git, create the accounts you need for deployment, and scaffold a fresh Next.js project with all the dependencies the portal requires.
In this step, get ready to:
- Install Node.js 20+ and Git on your machine.
- Create GitHub and Vercel accounts for version control and deployment.
- Scaffold the Next.js project and install all required dependencies.
Install Node.js and Git
The portal is built with Next.js, which runs on Node.js. Node.js version 20 or higher is required because Tailwind CSS v4 depends on it.
- Check your Node.js version by running this command in your terminal:
node --version
✔️ I see version 20 or higher
Your Node.js installation is ready. Move on to the Git check below.
ⓧ I see an older version
You have Node.js installed, but it is below version 20. You need to upgrade.
The easiest way is to use a version manager like nvm.
- If you already have nvm installed, run:
- If you do not have nvm, follow the instructions in the Command not found tab below to install Node.js fresh.
nvm install 20
nvm use 20
- After upgrading, confirm by running node --version again. You should see v20 or higher.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit the official Node.js download page at nodejs.org/en/download.
- Download and install the LTS version (24.x, which satisfies the 20+ requirement).
- Follow the installer prompts and accept the defaults.
- Close and reopen your terminal after installation.
- Run node --version again. You should see v20 or higher.
Next, check that Git is installed. Git handles version control for your project files.
- Check your Git installation by running:
git --version
✔️ I see a version number
Git is installed and ready. Move on to creating your accounts.
ⓧ Command not found
Git is not installed on your machine yet.
- Visit git-scm.com/install and follow the instructions for your operating system.
- On macOS, you can also install Git by running xcode-select --install in your terminal.
- On Windows, download the installer from the link above and follow the prompts.
- Close and reopen your terminal after installation.
- Run git --version again to confirm it works.
Now create the online accounts you need for this project.
- If you do not already have a GitHub account, create one at github.com.
- If you do not already have a Vercel account, create one at vercel.com. Sign up with your GitHub account for the smoothest experience.
Why Vercel?
Vercel is the company behind Next.js. It provides free hosting for Next.js apps and a free PostgreSQL database. You will deploy the finished portal here at no cost.
Scaffold the Next.js project
With your tools installed, you can now create the project. The create-next-app command generates a complete starter project with all the build tooling pre-configured.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running:
npx create-next-app@latest creative-support-portal
What will the terminal ask you?
The command will ask: "Would you like to use the recommended Next.js defaults?" Select Yes, use recommended defaults. This enables TypeScript, ESLint, Tailwind CSS, and the App Router for you automatically.
- Wait for the scaffolding to finish. You will see a success message once the project is created.
- Move into the new project folder by running:
cd creative-support-portal
- Open the project in your code editor.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the creative-support-portal folder, then click Open.
Using a different editor?
Any code editor works for this project. Open the creative-support-portal folder from your Desktop using your editor's file-open feature.
Don't see the project folder?
Make sure you ran cd ~/Desktop before the create-next-app command. If the folder ended up somewhere else, find it with find ~ -name creative-support-portal -type d and move it to your Desktop.
help me find my project folder
Install project dependencies
The scaffolded project includes Next.js, React, and Tailwind CSS. You still need to add the packages for database access, authentication, icons, and signature capture.
- Install the production dependencies by running:
npm install @prisma/client next-auth@beta lucide-react react-signature-canvas
What are these packages?
- @prisma/client is the database client that lets your app read and write data.
- next-auth@beta is Auth.js v5, the authentication library for login and role-based access.
- lucide-react provides the icon set used throughout the portal interface.
- react-signature-canvas enables the signature pad for timesheet declarations.
- Install Prisma as a development dependency by running:
npm install --save-dev prisma
Why is Prisma a dev dependency?
The prisma package is a CLI tool you use during development to manage database migrations and generate types. It does not ship with your production app. The @prisma/client package (installed above) is what runs in production.
- Confirm everything installed correctly by starting the development server:
npm run dev
- Open your browser and navigate to http://localhost:3000.
- You should see the default Next.js welcome page with the Next.js logo and links to documentation.
Page not loading?
Check your terminal for error messages. The most common cause is a port conflict. If port 3000 is already in use, Next.js will suggest an alternative port in the terminal output.
If you see dependency errors, delete the node_modules folder and package-lock.json file, then run npm install again.
help me fix my dev server
✔️ Awesome, I've got everything!
Great. Double check you have saved any changes and that the dev server is still running in your terminal.
ⓧ I'd like to double check the full code
Here is what your package.json should look like after all installations. Your exact version numbers may differ slightly, but the package names should match.
{
"name": "creative-support-portal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@prisma/client": "latest",
"next-auth": "beta",
"lucide-react": "latest",
"react-signature-canvas": "latest"
},
"devDependencies": {
"prisma": "latest",
"typescript": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"tailwindcss": "^4.3.0",
"eslint": "latest",
"eslint-config-next": "latest"
}
}
Your environment is ready and the project skeleton is in place. Next up, you will build the design system and accessibility foundation that makes this portal inclusive for all 5200+ care workers.
Build the Design System and Accessibility Foundation
Your project is scaffolded and running locally. Before building any pages, you need a solid visual foundation that every component can draw from.
In this step, you will create a design token system using Tailwind CSS v4's @theme directive, build an accessibility toolbar that persists user preferences, and wire up a responsive layout shell with proper ARIA landmarks.
In this step, get ready to:
- Define the full Creative Support colour palette, typography, spacing, and border-radius tokens in a single CSS file.
- Build a persistent accessibility toolbar with font scaling, high contrast mode, and a read-aloud toggle.
- Create a responsive layout shell with a desktop sidebar, mobile bottom tab bar, and skip-to-main-content link.
Create the design token system
Design tokens are the single source of truth for colours, fonts, spacing, and shapes across your entire portal. In Tailwind CSS v4, you define them directly in CSS using the @theme directive. This replaces the old tailwind.config.js file entirely.
- Open app/globals.css in your editor.
- Replace the entire file contents with the following design token system:
@import "tailwindcss";
/* Creative Support design token system */
@theme {
/* Brand colours */
--color-primary: #0891b2;
--color-primary-dark: #0e7490;
--color-primary-light: #22d3ee;
/* Warm neutrals */
--color-neutral-50: #fafafa;
--color-neutral-100: #f5f5f5;
--color-neutral-200: #e5e5e5;
--color-neutral-300: #d4d4d4;
--color-neutral-400: #a3a3a3;
--color-neutral-500: #737373;
--color-neutral-600: #525252;
--color-neutral-700: #404040;
--color-neutral-800: #262626;
--color-neutral-900: #171717;
/* Status colours */
--color-success: #16a34a;
--color-warning: #f59e0b;
--color-error: #dc2626;
--color-info: #2563eb;
/* Typography */
--font-sans: 'Inter', sans-serif;
/* Spacing scale */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border radius tokens */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
}
What does this code do?
The @import "tailwindcss" line loads Tailwind's base styles, utilities, and reset. This replaces the old @tailwind base directives from v3.
The @theme block defines design tokens as CSS custom properties that Tailwind automatically turns into utility classes. For example, --color-primary: #0891b2 creates utility classes like bg-primary, text-primary, and border-primary.
The design tokens cover the base theme. Now add the accessibility overrides below the @theme block.
- Add the following CSS below the closing } of the @theme block in the same file:
/* High contrast mode overrides */
[data-high-contrast] {
--color-primary: #06b6d4;
--color-neutral-50: #000000;
--color-neutral-100: #0a0a0a;
--color-neutral-800: #ffffff;
--color-neutral-900: #ffffff;
background-color: #0a0a0a;
color: #ffffff;
}
[data-high-contrast] * {
border-color: #ffffff !important;
}
/* Font size scaling */
[data-font-size="large"] {
font-size: 112.5%;
}
[data-font-size="xl"] {
font-size: 125%;
}
What does this code do?
The [data-high-contrast] selector activates when the accessibility toolbar sets a data-high-contrast attribute on the <html> element. It swaps the token values to high-contrast alternatives (black background, white text, yellow-shifted teal).
The [data-font-size] selectors scale the root font size up by 12.5% or 25%. Every rem-based size in the project scales proportionally. This meets WCAG 2.1 AA requirements for text resizing.
- Save app/globals.css.
- Check your browser at http://localhost:3000. You should see the page reset to a plain white background with no default Next.js styling.
Still seeing the default Next.js page?
Make sure you deleted all the previous content in app/globals.css before pasting the new tokens. The old Tailwind imports may conflict with the new @import "tailwindcss" statement.
If your dev server crashed, restart it by pressing Ctrl+C in the terminal and running npm run dev again.
help me debug my globals.css design tokens
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/globals.css before moving on.
ⓧ I'd like to double check the full code
@import "tailwindcss";
/* Creative Support design token system */
@theme {
/* Brand colours */
--color-primary: #0891b2;
--color-primary-dark: #0e7490;
--color-primary-light: #22d3ee;
/* Warm neutrals */
--color-neutral-50: #fafafa;
--color-neutral-100: #f5f5f5;
--color-neutral-200: #e5e5e5;
--color-neutral-300: #d4d4d4;
--color-neutral-400: #a3a3a3;
--color-neutral-500: #737373;
--color-neutral-600: #525252;
--color-neutral-700: #404040;
--color-neutral-800: #262626;
--color-neutral-900: #171717;
/* Status colours */
--color-success: #16a34a;
--color-warning: #f59e0b;
--color-error: #dc2626;
--color-info: #2563eb;
/* Typography */
--font-sans: 'Inter', sans-serif;
/* Spacing scale */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border radius tokens */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
}
/* High contrast mode overrides */
[data-high-contrast] {
--color-primary: #06b6d4;
--color-neutral-50: #000000;
--color-neutral-100: #0a0a0a;
--color-neutral-800: #ffffff;
--color-neutral-900: #ffffff;
background-color: #0a0a0a;
color: #ffffff;
}
[data-high-contrast] * {
border-color: #ffffff !important;
}
/* Font size scaling */
[data-font-size="large"] {
font-size: 112.5%;
}
[data-font-size="xl"] {
font-size: 125%;
}
Build the accessibility toolbar component
Creative Support serves care workers aged 18-65 with varying digital confidence levels. A persistent accessibility toolbar gives every user control over font size, contrast, and read-aloud preferences.
- Create a new folder called components in the root of your creative-support-portal project folder.
- Inside components/, create a new file called AccessibilityToolbar.tsx.
- Add the following code to components/AccessibilityToolbar.tsx:
'use client';
import { useState, useEffect } from 'react';
// Define the three font size levels
type FontSize = 'normal' | 'large' | 'xl';
export default function AccessibilityToolbar() {
// Track user preferences in state
const [fontSize, setFontSize] = useState<FontSize>('normal');
const [highContrast, setHighContrast] = useState(false);
const [readAloud, setReadAloud] = useState(false);
// Load saved preferences from localStorage on mount
useEffect(() => {
const savedFontSize = localStorage.getItem('a11y-font-size') as FontSize;
const savedHighContrast = localStorage.getItem('a11y-high-contrast');
const savedReadAloud = localStorage.getItem('a11y-read-aloud');
if (savedFontSize) setFontSize(savedFontSize);
if (savedHighContrast === 'true') setHighContrast(true);
if (savedReadAloud === 'true') setReadAloud(true);
}, []);
// Apply font size attribute to html element and persist
useEffect(() => {
document.documentElement.setAttribute('data-font-size', fontSize);
localStorage.setItem('a11y-font-size', fontSize);
}, [fontSize]);
// Apply high contrast attribute and persist
useEffect(() => {
if (highContrast) {
document.documentElement.setAttribute('data-high-contrast', '');
} else {
document.documentElement.removeAttribute('data-high-contrast');
}
localStorage.setItem('a11y-high-contrast', String(highContrast));
}, [highContrast]);
// Persist read-aloud preference
useEffect(() => {
localStorage.setItem('a11y-read-aloud', String(readAloud));
}, [readAloud]);
// Cycle through font sizes: normal -> large -> xl -> normal
const cycleFontSize = () => {
const sizes: FontSize[] = ['normal', 'large', 'xl'];
const currentIndex = sizes.indexOf(fontSize);
const nextIndex = (currentIndex + 1) % sizes.length;
setFontSize(sizes[nextIndex]);
};
// Display label for the current font size
const fontSizeLabel = fontSize === 'normal' ? 'A' : fontSize === 'large' ? 'A+' : 'A++';
return (
<div
className="fixed top-4 right-4 z-50 flex gap-2 rounded-lg bg-neutral-800 p-2 shadow-lg"
role="toolbar"
aria-label="Accessibility options"
>
{/* Font size toggle */}
<button
onClick={cycleFontSize}
aria-label={`Font size: ${fontSizeLabel}. Click to increase.`}
aria-pressed={fontSize !== 'normal'}
className="rounded-md bg-neutral-700 px-3 py-1 text-sm font-semibold text-white hover:bg-neutral-600"
>
{fontSizeLabel}
</button>
{/* High contrast toggle */}
<button
onClick={() => setHighContrast(!highContrast)}
aria-label="High contrast mode"
aria-pressed={highContrast}
className="rounded-md bg-neutral-700 px-3 py-1 text-sm font-semibold text-white hover:bg-neutral-600"
>
HC
</button>
{/* Read aloud toggle */}
<button
onClick={() => setReadAloud(!readAloud)}
aria-label="Read aloud toggle"
aria-pressed={readAloud}
className="rounded-md bg-neutral-700 px-3 py-1 text-sm font-semibold text-white hover:bg-neutral-600"
>
🔊
</button>
</div>
);
}
What does this code do?
This is a client component (marked with 'use client') because it needs browser APIs like localStorage and document.documentElement.
Each toggle button uses aria-pressed to communicate its state to screen readers. The toolbar sets data-font-size and data-high-contrast attributes on the <html> element, which triggers the CSS rules you wrote in globals.css.
Preferences persist across page reloads because they are saved to localStorage on every change and read back on mount.
- Save components/AccessibilityToolbar.tsx.
You will not see the toolbar in the browser yet. It needs to be imported into the layout first, which you will do after building the layout shell.
Seeing a TypeScript error?
If your editor highlights the FontSize type or the as FontSize cast, make sure you have the type FontSize = 'normal' | 'large' | 'xl' line before the component function.
help me fix TypeScript errors in my AccessibilityToolbar
✔️ Awesome, I've got everything!
Great. Make sure components/AccessibilityToolbar.tsx is saved.
ⓧ I'd like to double check the full code
'use client';
import { useState, useEffect } from 'react';
// Define the three font size levels
type FontSize = 'normal' | 'large' | 'xl';
export default function AccessibilityToolbar() {
// Track user preferences in state
const [fontSize, setFontSize] = useState<FontSize>('normal');
const [highContrast, setHighContrast] = useState(false);
const [readAloud, setReadAloud] = useState(false);
// Load saved preferences from localStorage on mount
useEffect(() => {
const savedFontSize = localStorage.getItem('a11y-font-size') as FontSize;
const savedHighContrast = localStorage.getItem('a11y-high-contrast');
const savedReadAloud = localStorage.getItem('a11y-read-aloud');
if (savedFontSize) setFontSize(savedFontSize);
if (savedHighContrast === 'true') setHighContrast(true);
if (savedReadAloud === 'true') setReadAloud(true);
}, []);
// Apply font size attribute to html element and persist
useEffect(() => {
document.documentElement.setAttribute('data-font-size', fontSize);
localStorage.setItem('a11y-font-size', fontSize);
}, [fontSize]);
// Apply high contrast attribute and persist
useEffect(() => {
if (highContrast) {
document.documentElement.setAttribute('data-high-contrast', '');
} else {
document.documentElement.removeAttribute('data-high-contrast');
}
localStorage.setItem('a11y-high-contrast', String(highContrast));
}, [highContrast]);
// Persist read-aloud preference
useEffect(() => {
localStorage.setItem('a11y-read-aloud', String(readAloud));
}, [readAloud]);
// Cycle through font sizes: normal -> large -> xl -> normal
const cycleFontSize = () => {
const sizes: FontSize[] = ['normal', 'large', 'xl'];
const currentIndex = sizes.indexOf(fontSize);
const nextIndex = (currentIndex + 1) % sizes.length;
setFontSize(sizes[nextIndex]);
};
// Display label for the current font size
const fontSizeLabel = fontSize === 'normal' ? 'A' : fontSize === 'large' ? 'A+' : 'A++';
return (
<div
className="fixed top-4 right-4 z-50 flex gap-2 rounded-lg bg-neutral-800 p-2 shadow-lg"
role="toolbar"
aria-label="Accessibility options"
>
{/* Font size toggle */}
<button
onClick={cycleFontSize}
aria-label={`Font size: ${fontSizeLabel}. Click to increase.`}
aria-pressed={fontSize !== 'normal'}
className="rounded-md bg-neutral-700 px-3 py-1 text-sm font-semibold text-white hover:bg-neutral-600"
>
{fontSizeLabel}
</button>
{/* High contrast toggle */}
<button
onClick={() => setHighContrast(!highContrast)}
aria-label="High contrast mode"
aria-pressed={highContrast}
className="rounded-md bg-neutral-700 px-3 py-1 text-sm font-semibold text-white hover:bg-neutral-600"
>
HC
</button>
{/* Read aloud toggle */}
<button
onClick={() => setReadAloud(!readAloud)}
aria-label="Read aloud toggle"
aria-pressed={readAloud}
className="rounded-md bg-neutral-700 px-3 py-1 text-sm font-semibold text-white hover:bg-neutral-600"
>
🔊
</button>
</div>
);
}
Implement the responsive layout shell
Care workers typically access the portal on their phones after long shifts. Line managers use desktop computers at their desks. The layout shell handles both scenarios with a mobile bottom tab bar and a desktop sidebar.
- Inside the components/ folder, create a new file called LayoutShell.tsx.
- Add the following code to components/LayoutShell.tsx:
'use client';
import Link from 'next/link';
import { Home, Clock, Timer, Calendar, ThermometerSun, HelpCircle } from 'lucide-react';
// Navigation links for both desktop sidebar and mobile tab bar
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home },
{ href: '/timesheet', label: 'Timesheet', icon: Clock },
{ href: '/overtime', label: 'Overtime', icon: Timer },
{ href: '/annual-leave', label: 'Annual Leave', icon: Calendar },
{ href: '/sickness', label: 'Sickness', icon: ThermometerSun },
{ href: '/help', label: 'Help', icon: HelpCircle },
];
export default function LayoutShell({ children }: { children: React.ReactNode }) {
return (
<>
{/* Skip to main content link for keyboard users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
>
Skip to main content
</a>
<div className="flex min-h-screen">
{/* Desktop sidebar navigation */}
<aside
role="navigation"
aria-label="Main navigation"
className="hidden w-64 flex-col border-r border-neutral-200 bg-neutral-50 p-4 md:flex"
>
<div className="mb-8 px-2 text-lg font-bold text-primary">
Creative Support
</div>
<ul className="flex flex-col gap-1">
{navLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-neutral-700 hover:bg-neutral-200"
>
<link.icon size={20} />
<span>{link.label}</span>
</Link>
</li>
))}
</ul>
</aside>
{/* Main content area */}
<main id="main-content" className="flex-1 p-4 pb-20 md:p-8 md:pb-8">
{children}
</main>
</div>
{/* Mobile bottom tab bar */}
<nav
role="navigation"
aria-label="Mobile navigation"
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-around border-t border-neutral-200 bg-white py-2 md:hidden"
>
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="flex flex-col items-center gap-1 px-2 py-1 text-neutral-600 hover:text-primary"
>
<link.icon size={20} />
<span className="text-xs">{link.label}</span>
</Link>
))}
</nav>
</>
);
}
What does this code do?
The <a href="#main-content"> link is visually hidden (sr-only) but becomes visible when focused via keyboard. This lets keyboard-only users jump past the navigation directly to the page content.
The <aside> uses role="navigation" and aria-label="Main navigation" so screen readers announce it properly. It is hidden on mobile (hidden md:flex) and visible on desktop.
The bottom <nav> does the reverse. It is visible on mobile and hidden on desktop (md:hidden). The main content has extra bottom padding (pb-20) on mobile so the tab bar never covers content.
- Save components/LayoutShell.tsx.
Now wire both components into the root layout so they appear on every page.
- Open app/layout.tsx and replace its entire contents with:
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import AccessibilityToolbar from '@/components/AccessibilityToolbar';
import LayoutShell from '@/components/LayoutShell';
// Load Inter font with Next.js font optimization
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Creative Support Portal',
description: 'Timesheet management portal for Creative Support staff',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>
<AccessibilityToolbar />
<LayoutShell>{children}</LayoutShell>
</body>
</html>
);
}
What does this code do?
The Inter import from next/font/google downloads the Inter font at build time and self-hosts it. No browser requests go to Google at runtime.
The inter.className applies the font to the <html> element so it cascades to every child. The AccessibilityToolbar renders outside the layout shell so it stays fixed regardless of scrolling. LayoutShell wraps all page content with the sidebar, main area, and mobile tab bar.
- Save app/layout.tsx.
- Refresh your browser at http://localhost:3000. You should see the sidebar navigation on the left with "Creative Support" at the top, and a dark toolbar with three buttons (A, HC, speaker icon) fixed in the top-right corner.
- Click the A button. The text on the page should grow slightly. Click it again for the largest size, and once more to reset.
- Click HC. The page should switch to a dark background with white text.
- Resize your browser window below 768px wide. The sidebar should disappear and a bottom tab bar should appear with icons and labels.
Toolbar buttons not doing anything?
Check that AccessibilityToolbar.tsx starts with 'use client' on the very first line. Without this directive, React treats it as a Server Component and browser APIs like localStorage are unavailable.
Also verify that the import paths in app/layout.tsx use @/components/ (the default path alias from create-next-app). If you see a module-not-found error, check that the components/ folder sits at the same level as the app/ folder.
help me fix my layout shell and accessibility toolbar
✔️ Awesome, I've got everything!
Great. Double check you have saved both components/LayoutShell.tsx and app/layout.tsx.
ⓧ I'd like to double check the full code
Here is the full app/layout.tsx:
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import AccessibilityToolbar from '@/components/AccessibilityToolbar';
import LayoutShell from '@/components/LayoutShell';
// Load Inter font with Next.js font optimization
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Creative Support Portal',
description: 'Timesheet management portal for Creative Support staff',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>
<AccessibilityToolbar />
<LayoutShell>{children}</LayoutShell>
</body>
</html>
);
}
Here is the full components/LayoutShell.tsx:
'use client';
import Link from 'next/link';
import { Home, Clock, Timer, Calendar, ThermometerSun, HelpCircle } from 'lucide-react';
// Navigation links for both desktop sidebar and mobile tab bar
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home },
{ href: '/timesheet', label: 'Timesheet', icon: Clock },
{ href: '/overtime', label: 'Overtime', icon: Timer },
{ href: '/annual-leave', label: 'Annual Leave', icon: Calendar },
{ href: '/sickness', label: 'Sickness', icon: ThermometerSun },
{ href: '/help', label: 'Help', icon: HelpCircle },
];
export default function LayoutShell({ children }: { children: React.ReactNode }) {
return (
<>
{/* Skip to main content link for keyboard users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
>
Skip to main content
</a>
<div className="flex min-h-screen">
{/* Desktop sidebar navigation */}
<aside
role="navigation"
aria-label="Main navigation"
className="hidden w-64 flex-col border-r border-neutral-200 bg-neutral-50 p-4 md:flex"
>
<div className="mb-8 px-2 text-lg font-bold text-primary">
Creative Support
</div>
<ul className="flex flex-col gap-1">
{navLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-neutral-700 hover:bg-neutral-200"
>
<link.icon size={20} />
<span>{link.label}</span>
</Link>
</li>
))}
</ul>
</aside>
{/* Main content area */}
<main id="main-content" className="flex-1 p-4 pb-20 md:p-8 md:pb-8">
{children}
</main>
</div>
{/* Mobile bottom tab bar */}
<nav
role="navigation"
aria-label="Mobile navigation"
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-around border-t border-neutral-200 bg-white py-2 md:hidden"
>
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="flex flex-col items-center gap-1 px-2 py-1 text-neutral-600 hover:text-primary"
>
<link.icon size={20} />
<span className="text-xs">{link.label}</span>
</Link>
))}
</nav>
</>
);
}
Why a skip-to-main-content link?
Screen reader and keyboard-only users would otherwise have to tab through every navigation link before reaching the page content. The skip link lets them jump straight to #main-content with a single keypress. This is a core WCAG 2.1 AA requirement for bypass blocks.
Your design system and accessibility foundations are in place. Next up, you will build the authentication layer so care workers and line managers each see the right content.
Set Up Authentication and Role-Based Access
Your design system and layout shell are ready. Now you need to control who can access what.
Creative Support has two distinct user roles: care workers who submit timesheets, and line managers who approve them. Each role sees a completely different dashboard. In this step, you'll wire up a database, authentication, and route protection so the right people land on the right pages.
In this step, get ready to:
- Set up a PostgreSQL database with Prisma and seed it with demo users.
- Configure Auth.js v5 with credentials login and role-aware sessions.
- Protect routes with middleware and build a split-screen login page.
Set up the Prisma schema and connect to Vercel Postgres
Before you can authenticate users, you need somewhere to store them. Prisma gives you a type-safe way to define your database models and query them from TypeScript.
- Initialize Prisma in your project by running this command:
npx prisma init --datasource-provider postgresql
What does this command do?
This creates a prisma/ directory containing schema.prisma (where you define your data models), a prisma.config.ts configuration file, and a .env file for your database connection string.
Next, you need a Vercel Postgres database to connect to.
- Log in to your Vercel dashboard at vercel.com.
- Click **Storage** in the top navigation.
- Click **Create Database**.
- Select **Postgres** and click **Continue**.
- Name your database creative-support-db and select a region close to you.
- Click **Create**.
- Once created, click the **Quickstart** tab and copy the DATABASE_URL connection string.
- Open the .env file that Prisma created in your project root. Replace the placeholder DATABASE_URL value with the connection string you just copied.
- Add an AUTH_SECRET value below it. This is used by Auth.js to encrypt session tokens. Generate a random string by running:
npx auth secret
- Confirm your .env file now contains both DATABASE_URL and AUTH_SECRET values.
Now define the User model in your Prisma schema. This model stores employee credentials and role information.
- Open prisma/schema.prisma and replace its contents with the following:
// Database connection configuration
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Generates the type-safe Prisma Client
generator client {
provider = "prisma-client-js"
}
// Two roles in the portal: care workers and line managers
enum Role {
CARE_WORKER
LINE_MANAGER
}
// Each user is a Creative Support employee
model User {
id String @id @default(cuid())
employeeNumber String @unique
passwordHash String
name String
role Role
service String
contractedHours Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
What does this schema do?
The enum Role defines the two access levels in the portal. The User model stores each employee's login credentials (employee number and hashed password), their role, which service they work in, and their contracted weekly hours.
The @unique constraint on employeeNumber ensures no two employees share the same login identifier. The @id @default(cuid()) generates a unique internal ID for each record.
- Generate the Prisma Client by running:
npx prisma generate
- Apply the schema to your database by running:
npx prisma migrate dev --name init
What do these commands do?
The generate command creates a type-safe client library tailored to your schema. The migrate dev command creates SQL migration files and applies them to your Vercel Postgres database, creating the actual tables.
- You should see a success message confirming the migration was applied.
Seeing a connection error?
Make sure your DATABASE_URL in .env is the full connection string from Vercel (starting with postgres://). Double-check there are no extra spaces or line breaks in the value.
help me fix my Prisma database connection error
Now seed the database with two demo users so you can test both roles.
- Install bcryptjs for password hashing and tsx for running TypeScript seed scripts by running:
npm install bcryptjs
npm install -D tsx @types/bcryptjs
- Create a new file called prisma/seed.ts and add the following code:
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Hash passwords for both demo users
const hashedPassword = await bcrypt.hash('password123', 10);
// Create a care worker account
await prisma.user.upsert({
where: { employeeNumber: '10001' },
update: {},
create: {
employeeNumber: '10001',
passwordHash: hashedPassword,
name: 'Alex Johnson',
role: 'CARE_WORKER',
service: 'Sunrise House',
contractedHours: 37.5,
},
});
// Create a line manager account
await prisma.user.upsert({
where: { employeeNumber: '20001' },
update: {},
create: {
employeeNumber: '20001',
passwordHash: hashedPassword,
name: 'Sam Taylor',
role: 'LINE_MANAGER',
service: 'Sunrise House',
contractedHours: 37.5,
},
});
console.log('Seeded 2 demo users successfully');
}
main()
.then(() => prisma.$disconnect())
.catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
What does this seed script do?
It creates two demo users: employee 10001 (a care worker named Alex Johnson) and employee 20001 (a line manager named Sam Taylor). Both belong to the Sunrise House service with 37.5 contracted hours.
The upsert method means the script is safe to run multiple times. If the user already exists, it does nothing. Passwords are hashed with bcrypt so they are never stored in plain text.
- Run the seed script:
npx tsx prisma/seed.ts
- You should see Seeded 2 demo users successfully in your terminal output.
Seed script failing?
If you see a module resolution error, make sure you installed tsx as a dev dependency. If you see a unique constraint error, the users already exist from a previous run.
help me debug my Prisma seed script
Configure Auth.js v5 with credentials login and role propagation
With users in the database, you need an authentication layer that verifies credentials and makes the user's role available throughout the app. Auth.js v5 provides a single auth() function that works in server components, route handlers, and middleware.
- Create the API route that Auth.js needs. Create the file app/api/auth/[...nextauth]/route.ts with the following code:
// Exposes the Auth.js GET and POST handlers at /api/auth/*
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
- Create the main Auth.js configuration file at the project root called auth.ts:
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
export const { handlers, auth, signIn, signOut } = NextAuth({
// Use JWT strategy so role is embedded in the token
session: { strategy: 'jwt' },
providers: [
CredentialsProvider({
name: 'Employee Login',
credentials: {
employeeNumber: { label: 'Employee Number', type: 'text' },
password: { label: 'Password', type: 'password' },
},
// Verify employee credentials against the database
async authorize(credentials) {
if (!credentials?.employeeNumber || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { employeeNumber: credentials.employeeNumber as string },
});
if (!user) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isValid) return null;
// Return user data to be encoded in the JWT
return {
id: user.id,
name: user.name,
role: user.role,
employeeNumber: user.employeeNumber,
};
},
}),
],
callbacks: {
// Persist role and employeeNumber in the JWT token
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.employeeNumber = user.employeeNumber;
}
return token;
},
// Expose role and employeeNumber on the session object
async session({ session, token }) {
if (session.user) {
session.user.role = token.role as string;
session.user.employeeNumber = token.employeeNumber as string;
}
return session;
},
},
pages: {
signIn: '/login',
},
});
What does this configuration do?
The CredentialsProvider defines a login flow using employee number and password. The authorize function looks up the user in the database, verifies the password hash with bcrypt, and returns the user data if valid.
The jwt callback adds the user's role and employee number to the encrypted token on first sign-in. The session callback then exposes those values to any component that calls auth(). This means the role is available everywhere without an extra database query per request.
Why JWT instead of database sessions?
With 5200+ care workers potentially logging in from phones at the end of shifts, you want authentication checks to be fast. JWT embeds the role directly in the token cookie. Every request can read the role without hitting the database. The tradeoff is that role changes require a new sign-in to take effect.
- Save auth.ts and app/api/auth/[...nextauth]/route.ts.
Create middleware for route protection and build the login page
Authentication alone does not control access. You need middleware that intercepts every request and redirects users who are not logged in or who are trying to access the wrong dashboard.
- Create middleware.ts in your project root with the following code:
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
// Protect dashboard and manager routes based on user role
export default auth((req) => {
const { nextUrl } = req;
const session = req.auth;
const isLoggedIn = !!session?.user;
// Redirect unauthenticated users to login
if (!isLoggedIn) {
return NextResponse.redirect(new URL('/login', nextUrl));
}
const userRole = session?.user?.role;
// Care worker routes require CARE_WORKER role
if (nextUrl.pathname.startsWith('/dashboard') && userRole !== 'CARE_WORKER') {
return NextResponse.redirect(new URL('/login?error=unauthorized', nextUrl));
}
// Manager routes require LINE_MANAGER role
if (nextUrl.pathname.startsWith('/manager') && userRole !== 'LINE_MANAGER') {
return NextResponse.redirect(new URL('/login?error=unauthorized', nextUrl));
}
return NextResponse.next();
});
// Only run middleware on these paths
export const config = {
matcher: ['/dashboard/:path*', '/manager/:path*'],
};
What does this middleware do?
The auth() wrapper from Auth.js automatically decodes the JWT and populates req.auth with the session data. The middleware checks two things: is the user logged in, and do they have the correct role for the route they are trying to access.
The matcher config ensures this middleware only runs on /dashboard/* and /manager/* paths. Public pages like the login page are not affected.
- Save middleware.ts.
Now build the login page. This is the first thing users see when they open the portal.
- Create the file app/login/page.tsx with the following code:
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
export default function LoginPage() {
// Track which role card is selected
const [selectedRole, setSelectedRole] = useState<'CARE_WORKER' | 'LINE_MANAGER' | null>(null);
const [error, setError] = useState('');
async function handleSubmit(formData: FormData) {
setError('');
const result = await signIn('credentials', {
employeeNumber: formData.get('employeeNumber') as string,
password: formData.get('password') as string,
role: selectedRole,
redirect: true,
callbackUrl: selectedRole === 'CARE_WORKER' ? '/dashboard' : '/manager',
});
if (result?.error) {
setError('Invalid employee number or password. Please try again.');
}
}
return (
<div className="min-h-screen flex">
{/* Left panel - branding */}
<div className="hidden lg:flex lg:w-1/2 bg-[var(--color-brand-teal)] items-center justify-center p-12">
<div className="text-white text-center">
<h1 className="text-4xl font-bold mb-4">Creative Support</h1>
<p className="text-lg opacity-90">Timesheet Portal</p>
</div>
</div>
{/* Right panel - login form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
<div className="w-full max-w-md space-y-8">
<h2 className="text-2xl font-bold text-center">Sign In</h2>
{/* Role selection cards */}
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setSelectedRole('CARE_WORKER')}
className={`p-4 rounded-lg border-2 text-center transition-colors ${
selectedRole === 'CARE_WORKER'
? 'border-[var(--color-brand-teal)] bg-[var(--color-brand-teal)]/10'
: 'border-gray-200 hover:border-gray-300'
}`}
aria-pressed={selectedRole === 'CARE_WORKER'}
>
<span className="font-medium">Care Worker</span>
</button>
<button
type="button"
onClick={() => setSelectedRole('LINE_MANAGER')}
className={`p-4 rounded-lg border-2 text-center transition-colors ${
selectedRole === 'LINE_MANAGER'
? 'border-[var(--color-brand-teal)] bg-[var(--color-brand-teal)]/10'
: 'border-gray-200 hover:border-gray-300'
}`}
aria-pressed={selectedRole === 'LINE_MANAGER'}
>
<span className="font-medium">Line Manager</span>
</button>
</div>
{/* Login form */}
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="employeeNumber" className="block text-sm font-medium mb-1">
Employee Number
</label>
<input
id="employeeNumber"
name="employeeNumber"
type="text"
required
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-[var(--color-brand-teal)] focus:border-transparent"
placeholder="e.g. 10001"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-[var(--color-brand-teal)] focus:border-transparent"
/>
</div>
{/* Error messages announced to screen readers */}
{error && (
<p className="text-red-600 text-sm" role="alert" aria-live="polite">
{error}
</p>
)}
<button
type="submit"
disabled={!selectedRole}
className="w-full py-3 px-4 bg-[var(--color-brand-teal)] text-white font-medium rounded-lg hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
Sign In
</button>
</form>
</div>
</div>
</div>
);
}
What does this login page do?
The page uses a split-screen layout. On desktop, the left half shows Creative Support branding. The right half contains the login form.
Before entering credentials, users select their role (Care Worker or Line Manager) using toggle cards with aria-pressed for screen reader accessibility. The submit button stays disabled until a role is chosen. Error messages appear in an aria-live region so screen readers announce them automatically.
- Save app/login/page.tsx.
- Start your development server by running:
npm run dev
- Open http://localhost:3000/login in your browser. You should see the split-screen login page with two role selection cards, an employee number field, and a password field.
- Select **Care Worker**, enter 10001 as the employee number and password123 as the password, then click **Sign In**.
- You should be redirected to /dashboard (which will show a 404 for now, since you have not built that page yet). This confirms authentication is working.
Login not working?
Make sure your .env file has both DATABASE_URL and AUTH_SECRET set. Check that you ran the seed script successfully and that the two demo users exist in your database.
If you see a NEXT_AUTH error in the console, confirm that your app/api/auth/[...nextauth]/route.ts file exports both GET and POST from the handlers.
help me debug my Auth.js login flow
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is the complete code for each file created in this step:
// Database connection configuration
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Generates the type-safe Prisma Client
generator client {
provider = "prisma-client-js"
}
// Two roles in the portal: care workers and line managers
enum Role {
CARE_WORKER
LINE_MANAGER
}
// Each user is a Creative Support employee
model User {
id String @id @default(cuid())
employeeNumber String @unique
passwordHash String
name String
role Role
service String
contractedHours Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Hash passwords for both demo users
const hashedPassword = await bcrypt.hash('password123', 10);
// Create a care worker account
await prisma.user.upsert({
where: { employeeNumber: '10001' },
update: {},
create: {
employeeNumber: '10001',
passwordHash: hashedPassword,
name: 'Alex Johnson',
role: 'CARE_WORKER',
service: 'Sunrise House',
contractedHours: 37.5,
},
});
// Create a line manager account
await prisma.user.upsert({
where: { employeeNumber: '20001' },
update: {},
create: {
employeeNumber: '20001',
passwordHash: hashedPassword,
name: 'Sam Taylor',
role: 'LINE_MANAGER',
service: 'Sunrise House',
contractedHours: 37.5,
},
});
console.log('Seeded 2 demo users successfully');
}
main()
.then(() => prisma.$disconnect())
.catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
export const { handlers, auth, signIn, signOut } = NextAuth({
// Use JWT strategy so role is embedded in the token
session: { strategy: 'jwt' },
providers: [
CredentialsProvider({
name: 'Employee Login',
credentials: {
employeeNumber: { label: 'Employee Number', type: 'text' },
password: { label: 'Password', type: 'password' },
},
// Verify employee credentials against the database
async authorize(credentials) {
if (!credentials?.employeeNumber || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { employeeNumber: credentials.employeeNumber as string },
});
if (!user) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isValid) return null;
// Return user data to be encoded in the JWT
return {
id: user.id,
name: user.name,
role: user.role,
employeeNumber: user.employeeNumber,
};
},
}),
],
callbacks: {
// Persist role and employeeNumber in the JWT token
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.employeeNumber = user.employeeNumber;
}
return token;
},
// Expose role and employeeNumber on the session object
async session({ session, token }) {
if (session.user) {
session.user.role = token.role as string;
session.user.employeeNumber = token.employeeNumber as string;
}
return session;
},
},
pages: {
signIn: '/login',
},
});
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
// Protect dashboard and manager routes based on user role
export default auth((req) => {
const { nextUrl } = req;
const session = req.auth;
const isLoggedIn = !!session?.user;
// Redirect unauthenticated users to login
if (!isLoggedIn) {
return NextResponse.redirect(new URL('/login', nextUrl));
}
const userRole = session?.user?.role;
// Care worker routes require CARE_WORKER role
if (nextUrl.pathname.startsWith('/dashboard') && userRole !== 'CARE_WORKER') {
return NextResponse.redirect(new URL('/login?error=unauthorized', nextUrl));
}
// Manager routes require LINE_MANAGER role
if (nextUrl.pathname.startsWith('/manager') && userRole !== 'LINE_MANAGER') {
return NextResponse.redirect(new URL('/login?error=unauthorized', nextUrl));
}
return NextResponse.next();
});
// Only run middleware on these paths
export const config = {
matcher: ['/dashboard/:path*', '/manager/:path*'],
};
// Exposes the Auth.js GET and POST handlers at /api/auth/*
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
export default function LoginPage() {
// Track which role card is selected
const [selectedRole, setSelectedRole] = useState<'CARE_WORKER' | 'LINE_MANAGER' | null>(null);
const [error, setError] = useState('');
async function handleSubmit(formData: FormData) {
setError('');
const result = await signIn('credentials', {
employeeNumber: formData.get('employeeNumber') as string,
password: formData.get('password') as string,
role: selectedRole,
redirect: true,
callbackUrl: selectedRole === 'CARE_WORKER' ? '/dashboard' : '/manager',
});
if (result?.error) {
setError('Invalid employee number or password. Please try again.');
}
}
return (
<div className="min-h-screen flex">
{/* Left panel - branding */}
<div className="hidden lg:flex lg:w-1/2 bg-[var(--color-brand-teal)] items-center justify-center p-12">
<div className="text-white text-center">
<h1 className="text-4xl font-bold mb-4">Creative Support</h1>
<p className="text-lg opacity-90">Timesheet Portal</p>
</div>
</div>
{/* Right panel - login form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
<div className="w-full max-w-md space-y-8">
<h2 className="text-2xl font-bold text-center">Sign In</h2>
{/* Role selection cards */}
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setSelectedRole('CARE_WORKER')}
className={`p-4 rounded-lg border-2 text-center transition-colors ${
selectedRole === 'CARE_WORKER'
? 'border-[var(--color-brand-teal)] bg-[var(--color-brand-teal)]/10'
: 'border-gray-200 hover:border-gray-300'
}`}
aria-pressed={selectedRole === 'CARE_WORKER'}
>
<span className="font-medium">Care Worker</span>
</button>
<button
type="button"
onClick={() => setSelectedRole('LINE_MANAGER')}
className={`p-4 rounded-lg border-2 text-center transition-colors ${
selectedRole === 'LINE_MANAGER'
? 'border-[var(--color-brand-teal)] bg-[var(--color-brand-teal)]/10'
: 'border-gray-200 hover:border-gray-300'
}`}
aria-pressed={selectedRole === 'LINE_MANAGER'}
>
<span className="font-medium">Line Manager</span>
</button>
</div>
{/* Login form */}
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="employeeNumber" className="block text-sm font-medium mb-1">
Employee Number
</label>
<input
id="employeeNumber"
name="employeeNumber"
type="text"
required
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-[var(--color-brand-teal)] focus:border-transparent"
placeholder="e.g. 10001"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-[var(--color-brand-teal)] focus:border-transparent"
/>
</div>
{/* Error messages announced to screen readers */}
{error && (
<p className="text-red-600 text-sm" role="alert" aria-live="polite">
{error}
</p>
)}
<button
type="submit"
disabled={!selectedRole}
className="w-full py-3 px-4 bg-[var(--color-brand-teal)] text-white font-medium rounded-lg hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
Sign In
</button>
</form>
</div>
</div>
</div>
);
}
Authentication and role-based access control are locked in. Next up, you'll build the personalised care worker dashboard that greets employees by name and shows their weekly progress.
Build the Care Worker Dashboard
With authentication working, your care workers can log in securely. Now they need a place to land after logging in.
The dashboard is the first thing a care worker sees after a long shift. It needs to feel personal, show their hours progress at a glance, and offer clear paths to every action they need. In this step, you will build four dashboard components that together create a warm, accessible home screen.
In this step, get ready to:
- Build a personalised welcome banner with a time-based greeting.
- Create a weekly hours progress widget with colour-coded states.
- Build a 6-card action grid and an important reminders panel.
Build the personalised welcome banner
Care workers often check their portal at different times of day. A dynamic greeting that says "Good morning" or "Good evening" makes the experience feel personal rather than institutional.
- Create the folder for your dashboard components by running this command:
mkdir -p components/dashboard
- Confirm the folder exists by running:
ls components
You should see dashboard listed in the output.
- Create a new file at components/dashboard/WelcomeBanner.tsx and add the following code:
// components/dashboard/WelcomeBanner.tsx
// Displays a personalised greeting that changes based on time of day
interface WelcomeBannerProps {
name: string;
service: string;
contractedHours: number;
}
export default function WelcomeBanner({ name, service, contractedHours }: WelcomeBannerProps) {
// Determine greeting based on current hour
const hour = new Date().getHours();
let greeting = "Good evening";
if (hour < 12) greeting = "Good morning";
else if (hour < 18) greeting = "Good afternoon";
return (
<section aria-label="Welcome banner" className="rounded-xl bg-white p-6 shadow-sm">
{/* Main greeting with the care worker's name */}
<h1 className="text-2xl font-semibold text-gray-900">
{greeting}, {name}
</h1>
{/* Service badge showing which service the worker belongs to */}
<span className="mt-2 inline-block rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-800">
{service}
</span>
{/* Contracted hours reminder */}
<p className="mt-2 text-sm text-gray-600">
Contracted hours: {contractedHours}h per week
</p>
</section>
);
}
What does this code do?
This component receives the care worker's name, service, and contracted hours as props. It checks the current hour on the server at render time to select the right greeting.
The aria-label on the section helps screen readers announce the purpose of this landmark. The heading uses h1 because it is the primary heading for the dashboard page.
You will not see a change yet. The next chunk creates the Prisma client utility and the dashboard page that renders this banner.
- Create a Prisma client singleton at lib/prisma.ts so you can query the database from Server Components:
// lib/prisma.ts
// Singleton Prisma client to avoid creating multiple connections in development
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
// Reuse existing client in development to prevent hot-reload connection exhaustion
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Why a singleton?
In development, Next.js hot-reloads your server code frequently. Without a singleton, each reload creates a new database connection until the pool is exhausted. Attaching the client to globalThis prevents this.
- Create the dashboard page at app/dashboard/page.tsx with the following code:
// app/dashboard/page.tsx
// Server Component: reads session, fetches user, renders dashboard widgets
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import WelcomeBanner from "@/components/dashboard/WelcomeBanner";
export default async function DashboardPage() {
// Check if the user is authenticated
const session = await auth();
if (!session?.user) {
redirect("/login");
}
// Fetch the full user record from the database
const user = await prisma.user.findUnique({
where: { employeeNumber: session.user.employeeNumber },
});
if (!user) {
redirect("/login");
}
return (
<div className="space-y-6 p-6">
<WelcomeBanner
name={user.name}
service={user.service}
contractedHours={user.contractedHours}
/>
</div>
);
}
What does this page do?
- auth() reads the JWT session from the request cookies.
- If no session exists, redirect("/login") sends the user back to the login page.
- prisma.user.findUnique fetches the care worker's record so you can pass their name, service, and hours to the banner.
- Save both files.
- Start the dev server if it is not already running:
npm run dev
- Open http://localhost:3000/login in your browser and log in with employee number 10001 and the password you seeded.
After logging in, you should see a personalised greeting like "Good morning, Alex Johnson" with a teal "Sunrise House" badge and "Contracted hours: 37.5h per week" below it.
Don't see the greeting?
- Make sure your Prisma client is generated. Run npx prisma generate if you see a missing client error.
- Confirm your .env.local has the correct DATABASE_URL pointing to your Vercel Postgres instance.
- Check the terminal for errors. A "redirect" loop usually means the session is missing. Re-check your auth.ts callbacks.
Why isn't my dashboard showing the welcome banner after login?
Create the weekly hours progress widget
Care workers need to see at a glance how many hours they have worked this week compared to their contract. A colour-coded progress bar communicates this instantly: green means on track, amber means approaching the limit, and blue means overtime.
- Create components/dashboard/WeeklyHoursWidget.tsx with the following code:
// components/dashboard/WeeklyHoursWidget.tsx
// Animated progress bar showing hours worked vs contracted, with colour states
interface WeeklyHoursWidgetProps {
workedHours: number;
contractedHours: number;
}
export default function WeeklyHoursWidget({ workedHours, contractedHours }: WeeklyHoursWidgetProps) {
// Calculate percentage of contracted hours worked
const percentage = Math.min((workedHours / contractedHours) * 100, 120);
// Determine colour state based on progress
// Green: on track (<90%), Amber: approaching limit (90-100%), Blue: overtime (>100%)
let colourClass = "bg-green-500";
let statusLabel = "On track";
if (percentage > 100) {
colourClass = "bg-blue-500";
statusLabel = "Overtime";
} else if (percentage >= 90) {
colourClass = "bg-amber-500";
statusLabel = "Approaching limit";
}
return (
<section aria-label="Weekly hours progress" className="rounded-xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900">Weekly Hours</h2>
{/* Status label */}
<p className="mt-1 text-sm text-gray-600">{statusLabel}</p>
{/* Accessible progress bar */}
<div
className="mt-4 h-4 w-full overflow-hidden rounded-full bg-gray-200"
role="progressbar"
aria-valuenow={workedHours}
aria-valuemin={0}
aria-valuemax={contractedHours}
aria-label={`${workedHours} of ${contractedHours} hours worked this week`}
>
<div
className={`h-full rounded-full transition-all duration-500 ${colourClass}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
{/* Hours summary text */}
<p className="mt-2 text-sm font-medium text-gray-700">
{workedHours}h / {contractedHours}h
</p>
</section>
);
}
What does this code do?
- The percentage calculation caps at 120 to prevent the bar overflowing visually.
- Three colour states give instant visual feedback without relying on colour alone. The statusLabel text provides the same information for screen reader users and for people who cannot distinguish the colours.
- The role="progressbar" with aria-valuenow, aria-valuemin, and aria-valuemax makes the bar fully accessible to assistive technology.
- Now add the widget to your dashboard page. In app/dashboard/page.tsx, find this line:
import WelcomeBanner from "@/components/dashboard/WelcomeBanner";
- Add the WeeklyHoursWidget import directly below it:
import WeeklyHoursWidget from "@/components/dashboard/WeeklyHoursWidget";
- In the same file, find the closing WelcomeBanner tag inside the return statement. Add the widget below it with a placeholder value for worked hours:
<WeeklyHoursWidget
workedHours={24}
contractedHours={user.contractedHours}
/>
Why is workedHours hardcoded?
You have not built the timesheet wizard yet, so there is no real hours data in the database. Using a placeholder value of 24 lets you verify the progress bar renders correctly now. You will replace this with real data in a later step.
- Save app/dashboard/page.tsx and refresh your browser.
You should see a progress bar below the welcome banner showing "24h / 37.5h" with a green fill (since 24 out of 37.5 is about 64%, well below the 90% amber threshold).
Progress bar not appearing?
- Check for TypeScript errors in your terminal. A missing prop or typo in the import path will prevent the component from rendering.
- Make sure you placed the <WeeklyHoursWidget /> element inside the <div> that wraps all dashboard content.
Help me debug why my WeeklyHoursWidget is not rendering on the dashboard.
Build the action cards and important reminders panel
The action cards give care workers a clear, visual path to every task they need. Each card uses a Lucide icon, a title, and a short description so the purpose is obvious even for users with low digital confidence.
- Create components/dashboard/ActionCards.tsx with the following code:
// components/dashboard/ActionCards.tsx
// Responsive 6-card grid linking to all main care worker actions
import Link from "next/link";
import {
Clock,
PlusCircle,
Calendar,
Send,
FileText,
Heart,
} from "lucide-react";
// Define the 6 action cards with their routes and icons
const cards = [
{
title: "Timesheet",
description: "Fill in your weekly timesheet",
href: "/timesheet",
icon: Clock,
},
{
title: "Overtime",
description: "Log additional hours worked",
href: "/overtime",
icon: PlusCircle,
},
{
title: "Annual Leave",
description: "Book time off",
href: "/annual-leave",
icon: Calendar,
},
{
title: "Submit & Track",
description: "Check submission status",
href: "/timesheet/history",
icon: Send,
},
{
title: "Previous Timesheets",
description: "View past timesheets",
href: "/timesheet/history",
icon: FileText,
},
{
title: "Report Sickness",
description: "Let us know you're unwell",
href: "/sickness",
icon: Heart,
},
];
export default function ActionCards() {
return (
<section aria-label="Quick actions">
<h2 className="text-lg font-semibold text-gray-900">Quick Actions</h2>
{/* Responsive grid: 2 columns on mobile, 3 on desktop */}
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3">
{cards.map((card) => (
<Link
key={card.title}
href={card.href}
className="flex flex-col items-center rounded-xl bg-white p-4 text-center shadow-sm transition-shadow hover:shadow-md focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
>
{/* Icon is decorative since the title conveys the meaning */}
<card.icon className="h-8 w-8 text-cyan-600" aria-hidden="true" />
<h3 className="mt-2 text-sm font-medium text-gray-900">{card.title}</h3>
<p className="mt-1 text-xs text-gray-500">{card.description}</p>
</Link>
))}
</div>
</section>
);
}
What does this code do?
- Each card is wrapped in a Next.js Link component, making the entire card a clickable link to the relevant page.
- The aria-hidden="true" on icons marks them as decorative. Screen readers skip them because the card's heading already describes the action.
- The focus:ring-2 class ensures keyboard users see a visible focus indicator on each card, meeting WCAG 2.1 AA focus-visibility requirements.
- Create components/dashboard/ImportantReminders.tsx with the following code:
// components/dashboard/ImportantReminders.tsx
// Static reminders panel displayed alongside the action cards
import { AlertCircle } from "lucide-react";
export default function ImportantReminders() {
return (
<aside
role="complementary"
aria-label="Important reminders"
className="rounded-xl bg-amber-50 p-6 shadow-sm"
>
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
<AlertCircle className="h-5 w-5 text-amber-600" aria-hidden="true" />
Important Reminders
</h2>
<ul className="mt-3 space-y-2 text-sm text-gray-700">
<li>Submit your timesheet by Friday 5pm.</li>
<li>Contact your manager for any queries about hours.</li>
<li>Check your annual leave balance before booking time off.</li>
<li>Report sickness before your shift starts when possible.</li>
</ul>
</aside>
);
}
What does this code do?
The aside element with role="complementary" tells assistive technology this is supplementary content. Screen reader users can skip directly to it or bypass it using landmark navigation.
- Now wire both new components into the dashboard page. In app/dashboard/page.tsx, find the WeeklyHoursWidget import and add these two imports below it:
import ActionCards from "@/components/dashboard/ActionCards";
import ImportantReminders from "@/components/dashboard/ImportantReminders";
- In the return statement, add both components below the WeeklyHoursWidget:
<ActionCards />
<ImportantReminders />
- Save app/dashboard/page.tsx and refresh your browser.
You should see a 6-card grid below the progress bar (2 columns on a narrow window, 3 on a wide one), followed by a warm amber reminders panel at the bottom.
Cards not showing or layout broken?
- Check that your Lucide icon imports match exactly: Clock, PlusCircle, Calendar, Send, FileText, Heart. Lucide icon names are PascalCase.
- If you see a module-not-found error for lucide-react, confirm it was installed in Step 1. Run npm install lucide-react if needed.
- Ensure ActionCards and ImportantReminders are placed inside the wrapping <div> in the return statement, not outside it.
My action cards or reminders panel are not appearing on the dashboard. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/dashboard/page.tsx at this point:
// app/dashboard/page.tsx
// Server Component: reads session, fetches user, renders dashboard widgets
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import WelcomeBanner from "@/components/dashboard/WelcomeBanner";
import WeeklyHoursWidget from "@/components/dashboard/WeeklyHoursWidget";
import ActionCards from "@/components/dashboard/ActionCards";
import ImportantReminders from "@/components/dashboard/ImportantReminders";
export default async function DashboardPage() {
// Check if the user is authenticated
const session = await auth();
if (!session?.user) {
redirect("/login");
}
// Fetch the full user record from the database
const user = await prisma.user.findUnique({
where: { employeeNumber: session.user.employeeNumber },
});
if (!user) {
redirect("/login");
}
// Placeholder weekly hours (will be replaced with real data in later steps)
const workedHours = 24;
return (
<div className="space-y-6 p-6">
<WelcomeBanner
name={user.name}
service={user.service}
contractedHours={user.contractedHours}
/>
<WeeklyHoursWidget
workedHours={workedHours}
contractedHours={user.contractedHours}
/>
<ActionCards />
<ImportantReminders />
</div>
);
}
Your care worker dashboard is live. Next up, you will build the 10-step timesheet wizard so care workers can actually log their hours.
Build the Timesheet Wizard (Steps 1–5)
Your dashboard gives care workers a clear overview of their week. Now it's time to build the feature they'll use most: the timesheet wizard.
A 10-step wizard breaks a complex form into manageable daily entries. Each step focuses on a single day, with auto-save protecting against lost work and care-specific fields capturing sleep-ins, on-call hours, and night supplements.
In this step, get ready to:
- Build the wizard framework with progress stepper, auto-save, and navigation controls.
- Create Step 1 (Your Details) and daily entry steps for Monday through Thursday.
- Implement care-specific accordion sections with info tooltips and smart warnings.
Build the wizard framework
Multi-step wizards need infrastructure before any step content can render. You need a way to track which step the user is on, show their progress visually, auto-save their work, and let them navigate forward and back.
You'll build five pieces: a Toast component for save notifications, a ProgressStepper for visual progress, an auto-save hook, navigation controls, and the main wizard page that ties everything together.
- Create a new file components/ui/Toast.tsx and add the following code:
'use client';
import { useState, useCallback, useEffect } from 'react';
// Define the shape of a single toast notification
interface ToastItem {
id: number;
message: string;
type: 'success' | 'info' | 'error';
}
// Singleton state so any component can trigger a toast
let toastListeners: ((toast: ToastItem) => void)[] = [];
let toastId = 0;
// Global function to show a toast from anywhere
export function showToast(message: string, type: 'success' | 'info' | 'error' = 'success') {
const toast: ToastItem = { id: toastId++, message, type };
toastListeners.forEach((listener) => listener(toast));
}
export default function Toast() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
useEffect(() => {
// Register this component as a listener for new toasts
const handler = (toast: ToastItem) => {
setToasts((prev) => [...prev, toast]);
// Auto-dismiss after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
}, 3000);
};
toastListeners.push(handler);
return () => {
toastListeners = toastListeners.filter((l) => l !== handler);
};
}, []);
return (
<div
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
role="status"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`rounded-lg px-4 py-3 text-sm font-medium shadow-lg transition-all ${
toast.type === 'success'
? 'bg-green-600 text-white'
: toast.type === 'error'
? 'bg-red-600 text-white'
: 'bg-teal-600 text-white'
}`}
>
{toast.message}
</div>
))}
</div>
);
}
What does this code do?
The Toast component manages a queue of notifications that appear in the bottom-right corner. The showToast function is exported separately so any component or hook can trigger a notification without prop drilling.
Each toast auto-dismisses after 3 seconds. The role="status" and aria-live="polite" attributes ensure screen readers announce toasts without interrupting the user's current task.
- Save components/ui/Toast.tsx.
- Now create components/timesheet/ProgressStepper.tsx with the following code:
'use client';
import { Check } from 'lucide-react';
interface ProgressStepperProps {
currentStep: number;
totalSteps: number;
}
// Step labels for the full desktop view
const stepLabels = [
'Details',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
'Review',
'Submit',
];
export default function ProgressStepper({ currentStep, totalSteps }: ProgressStepperProps) {
return (
<nav
aria-label="Timesheet progress"
className="sticky top-0 z-40 bg-white border-b border-neutral-200 py-3 px-4"
>
{/* Desktop: horizontal stepper with labels */}
<ol className="hidden md:flex items-center justify-between">
{stepLabels.map((label, index) => {
const stepNum = index + 1;
const isCompleted = stepNum < currentStep;
const isActive = stepNum === currentStep;
return (
<li key={stepNum} className="flex items-center gap-2">
<span
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
isCompleted
? 'bg-teal-600 text-white'
: isActive
? 'bg-teal-600 text-white ring-2 ring-teal-300'
: 'bg-neutral-200 text-neutral-500'
}`}
aria-current={isActive ? 'step' : undefined}
>
{isCompleted ? <Check className="w-4 h-4" /> : stepNum}
</span>
<span
className={`text-xs ${
isActive ? 'font-semibold text-teal-700' : 'text-neutral-500'
}`}
>
{label}
</span>
{/* Connector line between steps */}
{index < totalSteps - 1 && (
<span
className={`hidden lg:block h-0.5 w-6 ${
isCompleted ? 'bg-teal-600' : 'bg-neutral-200'
}`}
/>
)}
</li>
);
})}
</ol>
{/* Mobile: compact dot row */}
<div className="flex md:hidden items-center justify-center gap-1.5">
{Array.from({ length: totalSteps }, (_, i) => {
const stepNum = i + 1;
const isCompleted = stepNum < currentStep;
const isActive = stepNum === currentStep;
return (
<span
key={stepNum}
className={`rounded-full transition-all ${
isActive
? 'w-6 h-2.5 bg-teal-600'
: isCompleted
? 'w-2.5 h-2.5 bg-teal-600'
: 'w-2.5 h-2.5 bg-neutral-300'
}`}
aria-label={`Step ${stepNum}: ${stepLabels[i]}${
isCompleted ? ' (completed)' : isActive ? ' (current)' : ''
}`}
/>
);
})}
</div>
</nav>
);
}
What does this code do?
The ProgressStepper shows two views depending on screen size. On desktop, it renders a horizontal row of numbered circles with labels. On mobile, it collapses to a compact dot row where the active step is wider than the rest.
Completed steps show a checkmark icon. The active step gets a teal ring highlight. The sticky top-0 class keeps the stepper visible as the user scrolls through long day entries.
- Save components/timesheet/ProgressStepper.tsx.
- Create the auto-save hook at hooks/useTimesheetAutoSave.ts with this code:
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { showToast } from '@/components/ui/Toast';
const STORAGE_KEY = 'timesheet-draft';
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
export function useTimesheetAutoSave(formData: Record<string, unknown>) {
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Save current form data to localStorage
const saveDraft = useCallback(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
setLastSaved(new Date());
showToast('Draft saved', 'success');
} catch (error) {
showToast('Could not save draft', 'error');
}
}, [formData]);
// Clear saved draft (used after successful submission)
const clearDraft = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setLastSaved(null);
}, []);
// Set up the 30-second auto-save interval
useEffect(() => {
intervalRef.current = setInterval(() => {
saveDraft();
}, AUTO_SAVE_INTERVAL);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [saveDraft]);
// Also save when formData changes (debounced by the interval)
useEffect(() => {
const timeout = setTimeout(() => {
saveDraft();
}, AUTO_SAVE_INTERVAL);
return () => clearTimeout(timeout);
}, [formData, saveDraft]);
return { lastSaved, clearDraft, saveDraft };
}
Why auto-save every 30 seconds?
Care workers often fill in timesheets on their phones between shifts. If they get interrupted, lose signal, or accidentally close the browser, auto-save ensures their progress is protected.
The hook writes to localStorage under the key timesheet-draft. On the next visit, the wizard page loads this draft back into state so the user picks up where they left off.
- Save hooks/useTimesheetAutoSave.ts.
- Create the navigation controls at components/timesheet/WizardNav.tsx:
'use client';
import { ChevronLeft, ChevronRight, Save } from 'lucide-react';
interface WizardNavProps {
currentStep: number;
totalSteps: number;
onBack: () => void;
onNext: () => void;
onSaveDraft: () => void;
}
export default function WizardNav({
currentStep,
totalSteps,
onBack,
onNext,
onSaveDraft,
}: WizardNavProps) {
const isFirstStep = currentStep === 1;
const isLastStep = currentStep === totalSteps;
return (
<div className="flex items-center justify-between border-t border-neutral-200 pt-4 mt-6">
{/* Back button - disabled on step 1 */}
<button
onClick={onBack}
disabled={isFirstStep}
aria-label="Go to previous step"
className="flex items-center gap-1 px-4 py-2 text-sm font-medium rounded-lg
text-neutral-700 hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
{/* Save Draft button */}
<button
onClick={onSaveDraft}
aria-label="Save draft to continue later"
className="flex items-center gap-1 px-4 py-2 text-sm font-medium rounded-lg
text-teal-700 border border-teal-300 hover:bg-teal-50"
>
<Save className="w-4 h-4" />
Save Draft
</button>
{/* Next or Submit button */}
<button
onClick={onNext}
aria-label={isLastStep ? 'Submit timesheet' : 'Go to next step'}
className="flex items-center gap-1 px-4 py-2 text-sm font-medium rounded-lg
bg-teal-600 text-white hover:bg-teal-700"
>
{isLastStep ? 'Submit' : 'Next'}
{!isLastStep && <ChevronRight className="w-4 h-4" />}
</button>
</div>
);
}
What does this code do?
WizardNav renders three buttons: Back, Save Draft, and Next. The Back button is disabled on step 1 to prevent navigating before the wizard. On the final step, the Next button changes to "Submit" for clarity.
Every button includes an aria-label so screen reader users understand what each control does without relying on icon-only visual cues.
- Save components/timesheet/WizardNav.tsx.
- Now tie everything together by creating the main wizard page at app/timesheet/page.tsx:
'use client';
import { useState, useEffect } from 'react';
import ProgressStepper from '@/components/timesheet/ProgressStepper';
import WizardNav from '@/components/timesheet/WizardNav';
import Toast from '@/components/ui/Toast';
import { useTimesheetAutoSave } from '@/hooks/useTimesheetAutoSave';
const TOTAL_STEPS = 10;
const STORAGE_KEY = 'timesheet-draft';
export default function TimesheetPage() {
// Track which step the user is viewing
const [currentStep, setCurrentStep] = useState(1);
// Store all form data keyed by day/section
const [formData, setFormData] = useState<Record<string, unknown>>({});
// Load any saved draft from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
setFormData(JSON.parse(saved));
} catch {
// Invalid stored data, start fresh
}
}
}, []);
// Auto-save hook manages periodic saves and toast notifications
const { saveDraft } = useTimesheetAutoSave(formData);
// Navigation handlers
const handleNext = () => {
if (currentStep < TOTAL_STEPS) {
setCurrentStep((prev) => prev + 1);
saveDraft();
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep((prev) => prev - 1);
}
};
// Render the correct step component based on currentStep
// Steps 6-10 will be built in the next step of this project
const renderStep = () => {
switch (currentStep) {
case 1:
return <div>Step 1 placeholder</div>;
case 2:
return <div>Step 2 placeholder</div>;
case 3:
return <div>Step 3 placeholder</div>;
case 4:
return <div>Step 4 placeholder</div>;
case 5:
return <div>Step 5 placeholder</div>;
default:
return <div>Step {currentStep} - coming soon</div>;
}
};
return (
<div className="min-h-screen bg-neutral-50">
<ProgressStepper currentStep={currentStep} totalSteps={TOTAL_STEPS} />
<main className="max-w-3xl mx-auto px-4 py-6">
{renderStep()}
<WizardNav
currentStep={currentStep}
totalSteps={TOTAL_STEPS}
onBack={handleBack}
onNext={handleNext}
onSaveDraft={saveDraft}
/>
</main>
{/* Toast container for auto-save notifications */}
<Toast />
</div>
);
}
What does this code do?
This is the wizard's orchestrator. It holds the currentStep and formData state, loads any saved draft on mount, and renders the ProgressStepper, the active step component, and the WizardNav together.
The renderStep() function uses a switch to display the correct step. You'll replace the placeholders with real components next.
- Save app/timesheet/page.tsx.
- In your browser, navigate to http://localhost:3000/timesheet. You should see the sticky progress stepper at the top, placeholder text for Step 1, and Back/Save Draft/Next buttons at the bottom.
Don't see the stepper or navigation?
Make sure all four files are saved and your dev server is running. Check your terminal for import errors. The most common cause is a typo in the import path (e.g. @/components/timesheet/ProgressStepper must match the filename exactly).
help me debug why my timesheet page isn't rendering the wizard framework
Create Step 1 and daily entry steps for Monday through Thursday
Step 1 collects the care worker's identity details (pre-filled from their session) and the week-ending date. Steps 2 through 5 each represent a day of the week with a consistent pattern: toggle whether they worked, enter shift times, and see auto-calculated hours.
- Create components/timesheet/steps/Step1Details.tsx with the following code:
'use client';
interface Step1DetailsProps {
name: string;
employeeNumber: string;
service: string;
contractedHours: number;
weekEnding: string;
onWeekEndingChange: (date: string) => void;
}
export default function Step1Details({
name,
employeeNumber,
service,
contractedHours,
weekEnding,
onWeekEndingChange,
}: Step1DetailsProps) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-neutral-800">
Step 1: Your Details
</h2>
<p className="text-sm text-neutral-600">
These fields are auto-filled from your account. If anything looks wrong, contact your manager.
</p>
{/* Locked read-only fields from session */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Full Name
</label>
<input
type="text"
value={name}
readOnly
aria-label="Full name (read only)"
className="w-full px-3 py-2 bg-neutral-100 border border-neutral-200 rounded-lg text-neutral-600 cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Employee Number
</label>
<input
type="text"
value={employeeNumber}
readOnly
aria-label="Employee number (read only)"
className="w-full px-3 py-2 bg-neutral-100 border border-neutral-200 rounded-lg text-neutral-600 cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Service
</label>
<input
type="text"
value={service}
readOnly
aria-label="Service (read only)"
className="w-full px-3 py-2 bg-neutral-100 border border-neutral-200 rounded-lg text-neutral-600 cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Contracted Hours
</label>
<input
type="text"
value={`${contractedHours} hours/week`}
readOnly
aria-label="Contracted hours (read only)"
className="w-full px-3 py-2 bg-neutral-100 border border-neutral-200 rounded-lg text-neutral-600 cursor-not-allowed"
/>
</div>
</div>
{/* Week-ending date picker - the only editable field */}
<div className="max-w-xs">
<label
htmlFor="week-ending"
className="block text-sm font-medium text-neutral-700 mb-1"
>
Week Ending (Sunday)
</label>
<input
id="week-ending"
type="date"
value={weekEnding}
onChange={(e) => onWeekEndingChange(e.target.value)}
required
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
</div>
);
}
What does this code do?
Step 1 displays the care worker's name, employee number, service, and contracted hours as read-only fields. These come from the user's session data, so they cannot be accidentally changed.
The only editable field is the week-ending date picker. This tells the system which pay period this timesheet covers.
- Save components/timesheet/steps/Step1Details.tsx.
Now you'll build the day entry component. Steps 2 through 5 all follow the same pattern, so you'll build Monday first and then replicate it for the other days.
- Create components/timesheet/steps/Step2Monday.tsx with the following code:
'use client';
import { useState, useMemo } from 'react';
import DayAccordion from '@/components/timesheet/DayAccordion';
interface DayData {
worked: boolean;
startTime: string;
finishTime: string;
breakMinutes: number;
careExtras: Record<string, unknown>;
}
interface Step2MondayProps {
data: DayData;
onChange: (data: DayData) => void;
}
export default function Step2Monday({ data, onChange }: Step2MondayProps) {
// Auto-calculate net hours worked
const netHours = useMemo(() => {
if (!data.worked || !data.startTime || !data.finishTime) return 0;
const [startH, startM] = data.startTime.split(':').map(Number);
const [endH, endM] = data.finishTime.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
const totalMinutes = endMinutes - startMinutes - data.breakMinutes;
return Math.max(0, totalMinutes / 60);
}, [data.worked, data.startTime, data.finishTime, data.breakMinutes]);
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-neutral-800">
Step 2: Monday
</h2>
{/* Did you work today? toggle */}
<fieldset>
<legend className="text-sm font-medium text-neutral-700 mb-2">
Did you work on Monday?
</legend>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="monday-worked"
checked={data.worked === true}
onChange={() => onChange({ ...data, worked: true })}
className="w-4 h-4 text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm">Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="monday-worked"
checked={data.worked === false}
onChange={() => onChange({ ...data, worked: false })}
className="w-4 h-4 text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm">No</span>
</label>
</div>
</fieldset>
{/* Shift entry card - only shows when worked = true */}
{data.worked && (
<div className="border border-neutral-200 rounded-xl p-4 space-y-4 bg-white shadow-sm">
<h3 className="text-sm font-semibold text-neutral-700">
Shift Details
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Start time picker */}
<div>
<label
htmlFor="monday-start"
className="block text-sm text-neutral-600 mb-1"
>
Start Time
</label>
<input
id="monday-start"
type="time"
value={data.startTime}
onChange={(e) => onChange({ ...data, startTime: e.target.value })}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
{/* Finish time picker */}
<div>
<label
htmlFor="monday-finish"
className="block text-sm text-neutral-600 mb-1"
>
Finish Time
</label>
<input
id="monday-finish"
type="time"
value={data.finishTime}
onChange={(e) => onChange({ ...data, finishTime: e.target.value })}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
{/* Break duration input */}
<div>
<label
htmlFor="monday-break"
className="block text-sm text-neutral-600 mb-1"
>
Break (minutes)
</label>
<input
id="monday-break"
type="number"
min="0"
max="120"
value={data.breakMinutes}
onChange={(e) =>
onChange({ ...data, breakMinutes: parseInt(e.target.value) || 0 })
}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
</div>
{/* Auto-calculated hours display */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-100">
<span className="text-sm text-neutral-600">Net hours:</span>
<span className="text-lg font-semibold text-teal-700">
{netHours.toFixed(1)}h
</span>
</div>
</div>
)}
{/* Care-specific accordion sections */}
{data.worked && (
<DayAccordion
day="monday"
extras={data.careExtras}
onChange={(extras) => onChange({ ...data, careExtras: extras })}
/>
)}
</div>
);
}
What does this code do?
The day step has three sections. First, a yes/no radio toggle asks whether the care worker worked that day. If yes, a shift entry card appears with start time, finish time, and break duration inputs.
The netHours calculation uses useMemo to automatically compute (finish time minus start time minus break) whenever the inputs change. The DayAccordion at the bottom handles care-specific extras like sleep-ins and on-call.
- Save components/timesheet/steps/Step2Monday.tsx.
Steps 3 through 5 follow the exact same pattern as Step 2, just for different days. You'll create three more files with the day name and field IDs swapped.
- Create components/timesheet/steps/Step3Tuesday.tsx by copying the Step2Monday code with these changes:
- Rename the component to Step3Tuesday.
- Change the heading to Step 3: Tuesday.
- Replace all monday references in radio names and IDs with tuesday.
- Change the legend text to "Did you work on Tuesday?".
- Create components/timesheet/steps/Step4Wednesday.tsx following the same pattern with wednesday IDs and "Step 4: Wednesday" heading.
- Create components/timesheet/steps/Step5Thursday.tsx following the same pattern with thursday IDs and "Step 5: Thursday" heading.
Speed up with find-and-replace
Use your editor's find-and-replace feature. Press Cmd+H (macOS) or Ctrl+H (Windows) to swap all instances of monday with the target day in one action.
- Save all four step files.
Now update the wizard page to use the real step components instead of the placeholders.
- In app/timesheet/page.tsx, add imports for each step component at the top of the file:
import Step1Details from '@/components/timesheet/steps/Step1Details';
import Step2Monday from '@/components/timesheet/steps/Step2Monday';
import Step3Tuesday from '@/components/timesheet/steps/Step3Tuesday';
import Step4Wednesday from '@/components/timesheet/steps/Step4Wednesday';
import Step5Thursday from '@/components/timesheet/steps/Step5Thursday';
- Replace the placeholder renderStep() cases 1 through 5 with the real components, passing formData and setFormData as props.
- Save app/timesheet/page.tsx and refresh your browser at /timesheet. You should see Step 1 with greyed-out read-only fields and a date picker. Click Next to move to Monday and see the "Did you work?" toggle appear.
Seeing a blank page after clicking Next?
Check that each step file exports a default function component. Also verify that the import paths in app/timesheet/page.tsx match the actual filenames (case-sensitive on Linux/macOS).
help me debug why my wizard step components aren't rendering
Implement care-specific accordion sections with info tooltips and smart warnings
Care workers often have entries beyond standard shift hours. Sleep-ins, on-call periods, and night supplements each have different pay rules. Grouping these in collapsible accordion sections keeps the form clean while making these options easy to find.
- First create the tooltip helper at components/ui/InfoTooltip.tsx:
'use client';
import { useState, useId } from 'react';
import { HelpCircle } from 'lucide-react';
interface InfoTooltipProps {
text: string;
}
export default function InfoTooltip({ text }: InfoTooltipProps) {
const [isVisible, setIsVisible] = useState(false);
// Generate unique ID for aria-describedby linkage
const tooltipId = useId();
return (
<span className="relative inline-flex items-center">
<button
type="button"
aria-describedby={isVisible ? tooltipId : undefined}
aria-label="More information"
onClick={() => setIsVisible(!isVisible)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
className="text-neutral-400 hover:text-teal-600 focus:text-teal-600 transition-colors"
>
<HelpCircle className="w-4 h-4" />
</button>
{/* Tooltip popup */}
{isVisible && (
<span
id={tooltipId}
role="tooltip"
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2
text-xs text-white bg-neutral-800 rounded-lg shadow-lg
whitespace-normal max-w-[200px] z-50"
>
{text}
</span>
)}
</span>
);
}
What does this code do?
InfoTooltip renders a small question-mark icon that shows a tooltip on click or focus. The aria-describedby linkage connects the button to the tooltip content so screen readers announce the explanation when the button receives focus.
- Save components/ui/InfoTooltip.tsx.
- Create the DayAccordion component at components/timesheet/DayAccordion.tsx:
'use client';
import { useState } from 'react';
import { ChevronDown, AlertTriangle } from 'lucide-react';
import InfoTooltip from '@/components/ui/InfoTooltip';
interface DayAccordionProps {
day: string;
extras: Record<string, unknown>;
onChange: (extras: Record<string, unknown>) => void;
}
// Define accordion sections with tooltips
const sections = [
{
key: 'sleepIn',
label: 'Sleep-in',
tooltip: 'A sleep-in is when you stay overnight at a service but are not actively working unless called.',
},
{
key: 'onCall',
label: 'On-call',
tooltip: 'On-call means you are available to respond if needed but not actively at the service.',
},
{
key: 'nightSupplement',
label: 'Night Supplement',
tooltip: 'Night supplement applies to waking night shifts between 10pm and 7am.',
},
{
key: 'sickness',
label: 'Sickness',
tooltip: 'Record any hours you were unable to work due to illness.',
},
{
key: 'annualLeave',
label: 'Annual Leave',
tooltip: 'Record pre-approved annual leave hours for this day.',
},
{
key: 'otherLeave',
label: 'Other Leave',
tooltip: 'Includes compassionate leave, training days, or other approved absences.',
},
];
export default function DayAccordion({ day, extras, onChange }: DayAccordionProps) {
const [openSections, setOpenSections] = useState<string[]>([]);
// Toggle a section open or closed
const toggleSection = (key: string) => {
setOpenSections((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
// Update a specific extra field
const updateExtra = (key: string, value: unknown) => {
onChange({ ...extras, [key]: value });
};
// Smart warning: sleep-in and night supplement conflict
const hasSleepInAndNight =
extras.sleepIn === true && extras.nightSupplement === true;
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-neutral-700 mb-2">
Additional Entries
</h3>
{/* Smart warning for conflicting selections */}
{hasSleepInAndNight && (
<div
className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg"
role="alert"
>
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800">
You have selected both sleep-in and night supplement. These usually
do not apply to the same shift. Please check with your manager.
</p>
</div>
)}
{/* Accordion sections */}
{sections.map((section) => (
<div
key={section.key}
className="border border-neutral-200 rounded-lg overflow-hidden"
>
{/* Accordion header */}
<button
type="button"
onClick={() => toggleSection(section.key)}
aria-expanded={openSections.includes(section.key)}
className="flex items-center justify-between w-full px-4 py-3 text-sm
font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
<span className="flex items-center gap-2">
{section.label}
<InfoTooltip text={section.tooltip} />
</span>
<ChevronDown
className={`w-4 h-4 transition-transform ${
openSections.includes(section.key) ? 'rotate-180' : ''
}`}
/>
</button>
{/* Accordion content */}
{openSections.includes(section.key) && (
<div className="px-4 py-3 border-t border-neutral-100 bg-neutral-50">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={extras[section.key] === true}
onChange={(e) => updateExtra(section.key, e.target.checked)}
className="w-4 h-4 rounded text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm text-neutral-700">
Yes, {section.label.toLowerCase()} applies
</span>
</label>
{/* Hours input for sections that track hours */}
{extras[section.key] === true &&
section.key !== 'nightSupplement' && (
<div className="mt-3">
<label
htmlFor={`${day}-${section.key}-hours`}
className="block text-xs text-neutral-600 mb-1"
>
Hours
</label>
<input
id={`${day}-${section.key}-hours`}
type="number"
min="0"
max="24"
step="0.5"
value={(extras[`${section.key}Hours`] as number) || 0}
onChange={(e) =>
updateExtra(
`${section.key}Hours`,
parseFloat(e.target.value) || 0
)
}
className="w-24 px-3 py-1.5 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
)}
</div>
)}
</div>
))}
</div>
);
}
What does this code do?
DayAccordion renders six collapsible sections, each for a different care-specific entry type. Each section header includes an InfoTooltip explaining the term in plain English. When expanded, a checkbox activates the entry, and most sections also show a hours input.
The smart warning at the top watches for a conflict: if both sleep-in and night supplement are selected, it shows an amber alert. This catches a common payroll mistake before submission.
- Save components/timesheet/DayAccordion.tsx.
- Refresh your browser, navigate to the Monday step, select Yes for "Did you work?", then scroll down. You should see the "Additional Entries" accordion with six expandable sections. Click Sleep-in and Night Supplement, check both, and verify the amber warning appears.
Accordion sections not expanding?
Make sure the DayAccordion import in Step2Monday matches the file path exactly: @/components/timesheet/DayAccordion. Also check that you passed the extras and onChange props correctly.
help me debug why my DayAccordion sections won't expand
What is WCAG 2.1 AA?
WCAG 2.1 AA is the international accessibility standard for web content. Level AA requires a minimum 4.5:1 contrast ratio for normal text, keyboard operability for all functions, and clear focus indicators.
The accordion uses aria-expanded on each button and role="alert" on the smart warning. These attributes help screen readers communicate state changes to care workers who use assistive technology.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the main wizard page for reference:
'use client';
import { useState, useEffect } from 'react';
import ProgressStepper from '@/components/timesheet/ProgressStepper';
import WizardNav from '@/components/timesheet/WizardNav';
import Toast from '@/components/ui/Toast';
import { useTimesheetAutoSave } from '@/hooks/useTimesheetAutoSave';
import Step1Details from '@/components/timesheet/steps/Step1Details';
import Step2Monday from '@/components/timesheet/steps/Step2Monday';
import Step3Tuesday from '@/components/timesheet/steps/Step3Tuesday';
import Step4Wednesday from '@/components/timesheet/steps/Step4Wednesday';
import Step5Thursday from '@/components/timesheet/steps/Step5Thursday';
const TOTAL_STEPS = 10;
const STORAGE_KEY = 'timesheet-draft';
export default function TimesheetPage() {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<Record<string, unknown>>({});
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
setFormData(JSON.parse(saved));
} catch {
// Invalid stored data, start fresh
}
}
}, []);
const { saveDraft } = useTimesheetAutoSave(formData);
const handleNext = () => {
if (currentStep < TOTAL_STEPS) {
setCurrentStep((prev) => prev + 1);
saveDraft();
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep((prev) => prev - 1);
}
};
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<Step1Details
name={(formData.name as string) || ''}
employeeNumber={(formData.employeeNumber as string) || ''}
service={(formData.service as string) || ''}
contractedHours={(formData.contractedHours as number) || 0}
weekEnding={(formData.weekEnding as string) || ''}
onWeekEndingChange={(date) => setFormData({ ...formData, weekEnding: date })}
/>
);
case 2:
return (
<Step2Monday
data={(formData.monday as any) || { worked: false, startTime: '', finishTime: '', breakMinutes: 0, careExtras: {} }}
onChange={(data) => setFormData({ ...formData, monday: data })}
/>
);
case 3:
return (
<Step3Tuesday
data={(formData.tuesday as any) || { worked: false, startTime: '', finishTime: '', breakMinutes: 0, careExtras: {} }}
onChange={(data) => setFormData({ ...formData, tuesday: data })}
/>
);
case 4:
return (
<Step4Wednesday
data={(formData.wednesday as any) || { worked: false, startTime: '', finishTime: '', breakMinutes: 0, careExtras: {} }}
onChange={(data) => setFormData({ ...formData, wednesday: data })}
/>
);
case 5:
return (
<Step5Thursday
data={(formData.thursday as any) || { worked: false, startTime: '', finishTime: '', breakMinutes: 0, careExtras: {} }}
onChange={(data) => setFormData({ ...formData, thursday: data })}
/>
);
default:
return <div>Step {currentStep} - coming soon</div>;
}
};
return (
<div className="min-h-screen bg-neutral-50">
<ProgressStepper currentStep={currentStep} totalSteps={TOTAL_STEPS} />
<main className="max-w-3xl mx-auto px-4 py-6">
{renderStep()}
<WizardNav
currentStep={currentStep}
totalSteps={TOTAL_STEPS}
onBack={handleBack}
onNext={handleNext}
onSaveDraft={saveDraft}
/>
</main>
<Toast />
</div>
);
}
And here is the DayAccordion for reference:
'use client';
import { useState } from 'react';
import { ChevronDown, AlertTriangle } from 'lucide-react';
import InfoTooltip from '@/components/ui/InfoTooltip';
interface DayAccordionProps {
day: string;
extras: Record<string, unknown>;
onChange: (extras: Record<string, unknown>) => void;
}
const sections = [
{ key: 'sleepIn', label: 'Sleep-in', tooltip: 'A sleep-in is when you stay overnight at a service but are not actively working unless called.' },
{ key: 'onCall', label: 'On-call', tooltip: 'On-call means you are available to respond if needed but not actively at the service.' },
{ key: 'nightSupplement', label: 'Night Supplement', tooltip: 'Night supplement applies to waking night shifts between 10pm and 7am.' },
{ key: 'sickness', label: 'Sickness', tooltip: 'Record any hours you were unable to work due to illness.' },
{ key: 'annualLeave', label: 'Annual Leave', tooltip: 'Record pre-approved annual leave hours for this day.' },
{ key: 'otherLeave', label: 'Other Leave', tooltip: 'Includes compassionate leave, training days, or other approved absences.' },
];
export default function DayAccordion({ day, extras, onChange }: DayAccordionProps) {
const [openSections, setOpenSections] = useState<string[]>([]);
const toggleSection = (key: string) => {
setOpenSections((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
const updateExtra = (key: string, value: unknown) => {
onChange({ ...extras, [key]: value });
};
const hasSleepInAndNight = extras.sleepIn === true && extras.nightSupplement === true;
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-neutral-700 mb-2">Additional Entries</h3>
{hasSleepInAndNight && (
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg" role="alert">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800">
You have selected both sleep-in and night supplement. These usually do not apply to the same shift. Please check with your manager.
</p>
</div>
)}
{sections.map((section) => (
<div key={section.key} className="border border-neutral-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => toggleSection(section.key)}
aria-expanded={openSections.includes(section.key)}
className="flex items-center justify-between w-full px-4 py-3 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
<span className="flex items-center gap-2">
{section.label}
<InfoTooltip text={section.tooltip} />
</span>
<ChevronDown className={`w-4 h-4 transition-transform ${openSections.includes(section.key) ? 'rotate-180' : ''}`} />
</button>
{openSections.includes(section.key) && (
<div className="px-4 py-3 border-t border-neutral-100 bg-neutral-50">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={extras[section.key] === true}
onChange={(e) => updateExtra(section.key, e.target.checked)}
className="w-4 h-4 rounded text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm text-neutral-700">Yes, {section.label.toLowerCase()} applies</span>
</label>
{extras[section.key] === true && section.key !== 'nightSupplement' && (
<div className="mt-3">
<label htmlFor={`${day}-${section.key}-hours`} className="block text-xs text-neutral-600 mb-1">Hours</label>
<input
id={`${day}-${section.key}-hours`}
type="number"
min="0"
max="24"
step="0.5"
value={(extras[`${section.key}Hours`] as number) || 0}
onChange={(e) => updateExtra(`${section.key}Hours`, parseFloat(e.target.value) || 0)}
className="w-24 px-3 py-1.5 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
)}
</div>
)}
</div>
))}
</div>
);
}
Your timesheet wizard now handles the first five steps with a complete data entry flow. Next up, you'll build the remaining days (Friday through Sunday), the full week review, and the declaration and submit step.
Complete the Timesheet Wizard (Steps 6–10)
You have the first five wizard steps working. Care workers can enter their details and log shifts from Monday through Thursday.
Now you need the remaining days plus the parts that bring everything together: a running-total summary box, a full-week review screen with colour-coded hours, and a declaration page where the care worker signs and submits. These final pieces transform the wizard from a data-entry form into a complete, submission-ready workflow.
In this step, get ready to:
- Build Steps 6-8 (Friday to Sunday) and a daily summary box that shows running totals.
- Create Step 9 (Full Week Review) with a responsive summary table, colour-coded hours, TOIL section, and per-day edit buttons.
- Build Step 10 (Declaration and Submit) with dual signature options, a server action, and a post-submit success state.
Build Steps 6-8 and the daily summary box
Steps 6 through 8 follow the exact same pattern you built for Monday through Thursday. Each day component renders a "Did you work?" toggle, a shift entry card with time pickers, and the care-specific accordion sections.
- Create the file components/timesheet/steps/Step6Friday.tsx and add the following code:
'use client';
import { ShiftEntryCard } from '../ShiftEntryCard';
import { DayAccordion } from '../DayAccordion';
interface Step6FridayProps {
formData: any;
setFormData: (data: any) => void;
}
export function Step6Friday({ formData, setFormData }: Step6FridayProps) {
const dayData = formData.friday || {};
// Update the friday section of formData
function updateDay(field: string, value: any) {
setFormData({
...formData,
friday: { ...dayData, [field]: value },
});
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
Step 6: Friday
</h2>
{/* Did you work toggle */}
<fieldset>
<legend className="font-medium mb-2">Did you work on Friday?</legend>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
name="friday-worked"
value="yes"
checked={dayData.worked === true}
onChange={() => updateDay('worked', true)}
/>
Yes
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="friday-worked"
value="no"
checked={dayData.worked === false}
onChange={() => updateDay('worked', false)}
/>
No
</label>
</div>
</fieldset>
{/* Shift entry shown when worked */}
{dayData.worked && (
<ShiftEntryCard
startTime={dayData.startTime || ''}
finishTime={dayData.finishTime || ''}
breakMinutes={dayData.breakMinutes || 0}
onChange={(field, value) => updateDay(field, value)}
/>
)}
{/* Care-specific accordion sections */}
{dayData.worked && (
<DayAccordion
dayName="friday"
data={dayData}
onChange={(field, value) => updateDay(field, value)}
/>
)}
</div>
);
}
What does this code do?
This component follows the identical pattern you used for Steps 2-5. The updateDay function merges changes into the friday key of the shared formData object.
The conditional rendering ensures shift entry and accordion sections only appear when the care worker selects "Yes" for working that day. This keeps the interface clean on rest days.
- Create components/timesheet/steps/Step7Saturday.tsx with the same pattern, replacing every instance of friday with saturday and updating the heading and legend text to say "Saturday".
- Create components/timesheet/steps/Step8Sunday.tsx with the same pattern, replacing every instance with sunday and updating the heading and legend text to say "Sunday".
Copy and rename efficiently
Duplicate Step6Friday.tsx twice, then find-and-replace the day name in each copy. The component name, formData key, radio name attribute, and heading text all need updating.
Now you need a component that gives care workers a running total of their hours as they progress through the days.
- Create components/timesheet/DailySummaryBox.tsx and add this code:
'use client';
interface DailySummaryBoxProps {
formData: any;
contractedHours: number;
}
// Compute net hours for a single day entry
function getDayHours(dayData: any): number {
if (!dayData?.worked) return 0;
return dayData.netHours || 0;
}
export function DailySummaryBox({ formData, contractedHours }: DailySummaryBoxProps) {
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Calculate total weekly hours from all days
const totalHours = days.reduce((sum, day) => sum + getDayHours(formData[day]), 0);
return (
<aside
className="bg-white rounded-lg border border-[var(--color-border)] p-4
sticky bottom-0 md:bottom-auto md:top-24 md:self-start"
aria-label="Weekly hours summary"
>
<h3 className="font-semibold text-sm text-[var(--color-text-secondary)] mb-3">
Hours This Week
</h3>
{/* Per-day breakdown */}
<ul className="space-y-1 text-sm">
{days.map((day, i) => (
<li key={day} className="flex justify-between">
<span>{dayLabels[i]}</span>
<span className="font-medium">
{formData[day]?.worked ? `${getDayHours(formData[day])}h` : 'Rest day'}
</span>
</li>
))}
</ul>
{/* Total vs contracted comparison */}
<div className="border-t border-[var(--color-border)] mt-3 pt-3 flex justify-between font-semibold">
<span>Total</span>
<span>{totalHours}h / {contractedHours}h</span>
</div>
</aside>
);
}
What does this code do?
The DailySummaryBox computes per-day net hours and a weekly total from the shared formData object. It shows each day as either its worked hours or "Rest day".
The sticky bottom-0 md:bottom-auto md:top-24 classes make it a sticky bottom bar on mobile and a sticky sidebar element on desktop. This gives care workers constant visibility into their running totals without scrolling.
- Save all four files.
- You won't see these components rendered yet. The next substep wires them into the wizard page.
TypeScript errors on the new files?
Make sure ShiftEntryCard and DayAccordion are exported from their respective files. If your imports show red underlines, check that the relative path uses ../ShiftEntryCard (one level up from the steps/ folder).
help me fix TypeScript import errors in my timesheet step components
Create the full week review (Step 9)
Step 9 pulls all seven days together into a single summary screen. The care worker can scan their hours, spot discrepancies, and jump back to fix any day before submitting.
- Create components/timesheet/steps/Step9Review.tsx and add this code:
'use client';
interface Step9ReviewProps {
formData: any;
contractedHours: number;
onJumpToStep: (step: number) => void;
}
// Map day names to their wizard step numbers
const dayStepMap: Record<string, number> = {
monday: 2,
tuesday: 3,
wednesday: 4,
thursday: 5,
friday: 6,
saturday: 7,
sunday: 8,
};
// Determine colour class based on hours vs daily target
function getHoursColour(dayHours: number, dailyTarget: number): string {
if (dayHours === dailyTarget) return 'text-green-600 bg-green-50';
if (dayHours < dailyTarget) return 'text-amber-600 bg-amber-50';
return 'text-blue-600 bg-blue-50';
}
export function Step9Review({ formData, contractedHours, onJumpToStep }: Step9ReviewProps) {
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const dailyTarget = contractedHours / 7;
// Calculate weekly totals
const totalHours = days.reduce((sum, day) => {
return sum + (formData[day]?.netHours || 0);
}, 0);
const toilHours = Math.max(0, totalHours - contractedHours);
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
Step 9: Full Week Review
</h2>
{/* Desktop table view */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border)]">
<th className="text-left p-3">Day</th>
<th className="text-left p-3">Hours</th>
<th className="text-left p-3">Status</th>
<th className="text-right p-3">Action</th>
</tr>
</thead>
<tbody>
{days.map((day, i) => {
const dayHours = formData[day]?.netHours || 0;
const worked = formData[day]?.worked;
return (
<tr key={day} className="border-b border-[var(--color-border)]">
<td className="p-3 font-medium">{dayLabels[i]}</td>
<td className="p-3">{worked ? `${dayHours}h` : 'Rest day'}</td>
<td className="p-3">
{worked && (
<span className={`px-2 py-1 rounded text-sm ${getHoursColour(dayHours, dailyTarget)}`}>
{dayHours === dailyTarget ? 'On target' : dayHours < dailyTarget ? 'Under' : 'Over'}
</span>
)}
</td>
<td className="p-3 text-right">
<button
onClick={() => onJumpToStep(dayStepMap[day])}
className="text-[var(--color-teal)] hover:underline text-sm"
aria-label={`Edit ${dayLabels[i]}`}
>
Edit
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile card stack view */}
<div className="md:hidden space-y-3">
{days.map((day, i) => {
const dayHours = formData[day]?.netHours || 0;
const worked = formData[day]?.worked;
return (
<div key={day} className="p-4 rounded-lg border border-[var(--color-border)] bg-white">
<div className="flex justify-between items-center">
<span className="font-medium">{dayLabels[i]}</span>
<button
onClick={() => onJumpToStep(dayStepMap[day])}
className="text-[var(--color-teal)] hover:underline text-sm"
aria-label={`Edit ${dayLabels[i]}`}
>
Edit
</button>
</div>
<div className="mt-1 text-sm text-[var(--color-text-secondary)]">
{worked ? `${dayHours}h` : 'Rest day'}
{worked && (
<span className={`ml-2 px-2 py-0.5 rounded ${getHoursColour(dayHours, dailyTarget)}`}>
{dayHours === dailyTarget ? 'On target' : dayHours < dailyTarget ? 'Under' : 'Over'}
</span>
)}
</div>
</div>
);
})}
</div>
{/* TOIL section shown when hours exceed contracted */}
{toilHours > 0 && (
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
<p className="font-medium text-blue-800">
Time Off In Lieu (TOIL): {toilHours}h
</p>
<p className="text-sm text-blue-600 mt-1">
You worked {toilHours}h over your contracted {contractedHours}h this week.
</p>
</div>
)}
{/* Weekly total */}
<div className="p-4 rounded-lg bg-[var(--color-bg-subtle)] border border-[var(--color-border)]">
<div className="flex justify-between font-semibold">
<span>Total Weekly Hours</span>
<span>{totalHours}h / {contractedHours}h</span>
</div>
</div>
</div>
);
}
What does this code do?
- getHoursColour returns green classes when hours match the daily target, amber when under, and blue when over. This gives immediate visual feedback.
- The desktop view renders a full <table> with Day, Hours, Status, and Action columns. The mobile view renders a card stack using md:hidden and hidden md:block to toggle visibility.
- Each Edit button calls onJumpToStep with the day's wizard step number from dayStepMap. This lets care workers jump back to fix a specific day.
- The TOIL section only appears when total hours exceed contracted hours, calculated as totalHours - contractedHours.
- Save Step9Review.tsx.
Colour coding not showing correctly?
The colour classes (text-green-600, bg-green-50, etc.) are Tailwind utility classes. Make sure your tailwind content scanning includes the components/ directory. With Tailwind v4 and the App Router, this is typically automatic.
help me debug Tailwind colour classes not rendering in my Step9Review component
Build the declaration page and submission flow (Step 10)
The final wizard step is where the care worker formally declares their hours are correct, signs the timesheet, and submits it. You need a database model to persist the submission and a server action to handle the write.
- Open prisma/schema.prisma and add the following models below your existing User model:
// Timesheet submission record
model Timesheet {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
weekEnding DateTime
status TimesheetStatus @default(DRAFT)
totalHours Float?
toilHours Float?
submittedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
days TimesheetDay[]
}
// Individual day entries linked to a timesheet
model TimesheetDay {
id String @id @default(cuid())
timesheetId String
timesheet Timesheet @relation(fields: [timesheetId], references: [id])
dayName String
worked Boolean
startTime String?
finishTime String?
breakMinutes Int?
netHours Float?
sleepIn Boolean @default(false)
onCall Boolean @default(false)
nightSupplement Boolean @default(false)
sicknessHours Float?
annualLeaveHours Float?
otherLeaveHours Float?
otherLeaveType String?
}
// Status progression for timesheets
enum TimesheetStatus {
DRAFT
SUBMITTED
APPROVED
AMENDMENT_REQUESTED
REJECTED
}
What does this schema do?
The Timesheet model holds the overall submission with status tracking (Draft, Submitted, Approved, Amendment Requested, Rejected). Each timesheet has many TimesheetDay records storing the per-day details.
The TimesheetStatus enum models the approval workflow. A freshly submitted timesheet starts as SUBMITTED and moves through states as the line manager acts on it.
- Save prisma/schema.prisma.
- Run the migration to update your database by running:
npx prisma db push
- You should see a message confirming your database is in sync with the schema.
Next, create the server action that handles timesheet submission.
- Create the file actions/submitTimesheet.ts and add this code:
'use server';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
// Server action to submit a completed timesheet
export async function submitTimesheet(formData: any) {
// Verify the user is authenticated
const session = await auth();
if (!session?.user?.id) {
throw new Error('Not authenticated');
}
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
// Calculate total hours across all days
const totalHours = days.reduce((sum, day) => {
return sum + (formData[day]?.netHours || 0);
}, 0);
// Create timesheet with nested day records in one transaction
const timesheet = await prisma.timesheet.create({
data: {
userId: session.user.id,
weekEnding: new Date(formData.weekEnding),
status: 'SUBMITTED',
totalHours,
toilHours: Math.max(0, totalHours - (formData.contractedHours || 0)),
submittedAt: new Date(),
days: {
create: days.map((day) => ({
dayName: day,
worked: formData[day]?.worked || false,
startTime: formData[day]?.startTime || null,
finishTime: formData[day]?.finishTime || null,
breakMinutes: formData[day]?.breakMinutes || null,
netHours: formData[day]?.netHours || null,
sleepIn: formData[day]?.sleepIn || false,
onCall: formData[day]?.onCall || false,
nightSupplement: formData[day]?.nightSupplement || false,
sicknessHours: formData[day]?.sicknessHours || null,
annualLeaveHours: formData[day]?.annualLeaveHours || null,
otherLeaveHours: formData[day]?.otherLeaveHours || null,
otherLeaveType: formData[day]?.otherLeaveType || null,
})),
},
},
});
return { success: true, timesheetId: timesheet.id };
}
What does this server action do?
- auth() verifies the session and extracts the user ID. The action throws if unauthenticated.
- prisma.timesheet.create uses a nested write to create the parent Timesheet and all seven TimesheetDay records in a single database transaction.
- TOIL is calculated server-side as the difference between total worked hours and contracted hours, ensuring consistency even if the client-side calculation drifts.
- Save actions/submitTimesheet.ts.
Now build the declaration component that uses this server action.
- Create components/timesheet/steps/Step10Declaration.tsx and add this code:
'use client';
import { useState, useRef } from 'react';
import SignatureCanvas from 'react-signature-canvas';
import { submitTimesheet } from '@/actions/submitTimesheet';
interface Step10DeclarationProps {
formData: any;
}
export function Step10Declaration({ formData }: Step10DeclarationProps) {
const [declared, setDeclared] = useState(false);
const [signatureMode, setSignatureMode] = useState<'draw' | 'type'>('draw');
const [typedName, setTypedName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [timesheetId, setTimesheetId] = useState<string | null>(null);
const sigCanvas = useRef<SignatureCanvas>(null);
// Handle form submission via server action
async function handleSubmit() {
setIsSubmitting(true);
const result = await submitTimesheet(formData);
if (result.success) {
setTimesheetId(result.timesheetId);
setSubmitted(true);
localStorage.removeItem('timesheet-draft');
}
setIsSubmitting(false);
}
// Clear the signature canvas
function clearSignature() {
sigCanvas.current?.clear();
}
// Post-submit success state
if (submitted) {
return (
<div className="text-center space-y-6 py-12">
<div className="text-6xl animate-bounce" aria-hidden="true">✓</div>
<h2 className="text-2xl font-semibold text-green-700">
Timesheet Submitted
</h2>
<p className="text-[var(--color-text-secondary)]">
Your timesheet has been sent to your line manager for approval.
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => window.print()}
className="px-4 py-2 rounded-lg border border-[var(--color-border)] hover:bg-gray-50"
>
Download PDF
</button>
<a
href="/dashboard"
className="px-4 py-2 rounded-lg bg-[var(--color-teal)] text-white hover:opacity-90"
>
Back to Dashboard
</a>
</div>
</div>
);
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
Step 10: Declaration and Submit
</h2>
{/* Declaration checkbox */}
<label className="flex items-start gap-3 p-4 rounded-lg border border-[var(--color-border)]">
<input
type="checkbox"
checked={declared}
onChange={(e) => setDeclared(e.target.checked)}
className="mt-1"
required
aria-required="true"
/>
<span className="text-sm">
I declare that the information I have given on this timesheet is correct
and complete. I understand that providing false information may result
in disciplinary action.
</span>
</label>
{/* Signature section */}
<div className="space-y-4">
<h3 className="font-medium">Your Signature</h3>
{/* Signature mode tabs */}
<div className="flex gap-2" role="tablist">
<button
role="tab"
aria-selected={signatureMode === 'draw'}
onClick={() => setSignatureMode('draw')}
className={`px-4 py-2 rounded-lg text-sm ${
signatureMode === 'draw'
? 'bg-[var(--color-teal)] text-white'
: 'bg-gray-100 text-[var(--color-text-secondary)]'
}`}
>
Draw
</button>
<button
role="tab"
aria-selected={signatureMode === 'type'}
onClick={() => setSignatureMode('type')}
className={`px-4 py-2 rounded-lg text-sm ${
signatureMode === 'type'
? 'bg-[var(--color-teal)] text-white'
: 'bg-gray-100 text-[var(--color-text-secondary)]'
}`}
>
Type
</button>
</div>
{/* Draw signature option */}
{signatureMode === 'draw' && (
<div className="space-y-2">
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{
className: 'w-full h-40 bg-white',
'aria-label': 'Signature drawing area',
}}
/>
</div>
<button
onClick={clearSignature}
className="text-sm text-[var(--color-teal)] hover:underline"
>
Clear signature
</button>
</div>
)}
{/* Type signature option */}
{signatureMode === 'type' && (
<div className="space-y-2">
<input
type="text"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder="Type your full name"
className="w-full p-3 border border-[var(--color-border)] rounded-lg"
aria-label="Typed signature"
/>
{typedName && (
<p
className="text-2xl p-4 border border-dashed border-[var(--color-border)] rounded-lg"
style={{ fontFamily: "'Dancing Script', cursive" }}
>
{typedName}
</p>
)}
</div>
)}
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={!declared || isSubmitting}
className="w-full py-3 rounded-lg bg-[var(--color-teal)] text-white font-medium
disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
aria-label="Submit timesheet"
>
{isSubmitting ? 'Submitting...' : 'Submit Timesheet'}
</button>
</div>
);
}
What does this component do?
- The declaration checkbox is required and gates the submit button via the disabled={!declared} prop. Care workers cannot submit without acknowledging the declaration.
- The Draw tab uses SignatureCanvas from react-signature-canvas. The ref gives access to clear() for resetting the pad.
- The Type tab renders the input value in Dancing Script (a handwriting font) to give it a signature-like appearance.
- On successful submission, the component removes the timesheet-draft localStorage key and shows a success card with a PDF download button and a link back to the dashboard.
Add Dancing Script font
Add the Dancing Script font to your app/layout.tsx by including a Google Fonts link in the <head>. This ensures the typed signature renders in a handwriting style. Without it, the typed name falls back to the system cursive font.
Finally, update the wizard page to render all ten steps including the new components.
- Open app/timesheet/page.tsx and add imports for the new step components at the top of the file:
import { Step6Friday } from '@/components/timesheet/steps/Step6Friday';
import { Step7Saturday } from '@/components/timesheet/steps/Step7Saturday';
import { Step8Sunday } from '@/components/timesheet/steps/Step8Sunday';
import { Step9Review } from '@/components/timesheet/steps/Step9Review';
import { Step10Declaration } from '@/components/timesheet/steps/Step10Declaration';
import { DailySummaryBox } from '@/components/timesheet/DailySummaryBox';
- In the same file, find the section that renders the current step component based on currentStep. Add cases for steps 6 through 10:
// Inside the step rendering logic, add these cases:
{currentStep === 6 && <Step6Friday formData={formData} setFormData={setFormData} />}
{currentStep === 7 && <Step7Saturday formData={formData} setFormData={setFormData} />}
{currentStep === 8 && <Step8Sunday formData={formData} setFormData={setFormData} />}
{currentStep === 9 && (
<Step9Review
formData={formData}
contractedHours={formData.contractedHours || 37.5}
onJumpToStep={(step) => setCurrentStep(step)}
/>
)}
{currentStep === 10 && <Step10Declaration formData={formData} />}
- Also render the DailySummaryBox alongside the step content for steps 2 through 8. Add it as a sibling element in the layout:
// Render the summary box alongside day steps (2-8)
{currentStep >= 2 && currentStep <= 8 && (
<DailySummaryBox
formData={formData}
contractedHours={formData.contractedHours || 37.5}
/>
)}
What does this wiring do?
The DailySummaryBox renders alongside the active day step (steps 2-8) so care workers always see their running total. On Step 9, the review component has its own built-in summary, so the box hides.
The onJumpToStep prop on Step9Review calls setCurrentStep directly. This lets the Edit buttons navigate back to any day without losing form data (since it lives in shared state).
- Save app/timesheet/page.tsx.
- Run your development server and navigate to the timesheet wizard. Step through all 10 steps. On the review page, you should see colour-coded hours and working Edit buttons. On the declaration page, the signature pad should accept drawing input.
Signature canvas not rendering?
Make sure you installed react-signature-canvas in Step 1. If the import fails, run:
npm i react-signature-canvas
Also ensure you have the TypeScript types installed with npm i -D @types/react-signature-canvas. Without types, your IDE may show errors even though the component renders correctly at runtime.
help me fix SignatureCanvas not rendering in my Step10Declaration component
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the complete Step10Declaration.tsx for reference. Compare it against your file:
'use client';
import { useState, useRef } from 'react';
import SignatureCanvas from 'react-signature-canvas';
import { submitTimesheet } from '@/actions/submitTimesheet';
interface Step10DeclarationProps {
formData: any;
}
export function Step10Declaration({ formData }: Step10DeclarationProps) {
const [declared, setDeclared] = useState(false);
const [signatureMode, setSignatureMode] = useState<'draw' | 'type'>('draw');
const [typedName, setTypedName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [timesheetId, setTimesheetId] = useState<string | null>(null);
const sigCanvas = useRef<SignatureCanvas>(null);
async function handleSubmit() {
setIsSubmitting(true);
const result = await submitTimesheet(formData);
if (result.success) {
setTimesheetId(result.timesheetId);
setSubmitted(true);
localStorage.removeItem('timesheet-draft');
}
setIsSubmitting(false);
}
function clearSignature() {
sigCanvas.current?.clear();
}
if (submitted) {
return (
<div className="text-center space-y-6 py-12">
<div className="text-6xl animate-bounce" aria-hidden="true">✓</div>
<h2 className="text-2xl font-semibold text-green-700">
Timesheet Submitted
</h2>
<p className="text-[var(--color-text-secondary)]">
Your timesheet has been sent to your line manager for approval.
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => window.print()}
className="px-4 py-2 rounded-lg border border-[var(--color-border)] hover:bg-gray-50"
>
Download PDF
</button>
<a
href="/dashboard"
className="px-4 py-2 rounded-lg bg-[var(--color-teal)] text-white hover:opacity-90"
>
Back to Dashboard
</a>
</div>
</div>
);
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
Step 10: Declaration and Submit
</h2>
<label className="flex items-start gap-3 p-4 rounded-lg border border-[var(--color-border)]">
<input
type="checkbox"
checked={declared}
onChange={(e) => setDeclared(e.target.checked)}
className="mt-1"
required
aria-required="true"
/>
<span className="text-sm">
I declare that the information I have given on this timesheet is correct
and complete. I understand that providing false information may result
in disciplinary action.
</span>
</label>
<div className="space-y-4">
<h3 className="font-medium">Your Signature</h3>
<div className="flex gap-2" role="tablist">
<button
role="tab"
aria-selected={signatureMode === 'draw'}
onClick={() => setSignatureMode('draw')}
className={`px-4 py-2 rounded-lg text-sm ${
signatureMode === 'draw'
? 'bg-[var(--color-teal)] text-white'
: 'bg-gray-100 text-[var(--color-text-secondary)]'
}`}
>
Draw
</button>
<button
role="tab"
aria-selected={signatureMode === 'type'}
onClick={() => setSignatureMode('type')}
className={`px-4 py-2 rounded-lg text-sm ${
signatureMode === 'type'
? 'bg-[var(--color-teal)] text-white'
: 'bg-gray-100 text-[var(--color-text-secondary)]'
}`}
>
Type
</button>
</div>
{signatureMode === 'draw' && (
<div className="space-y-2">
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{
className: 'w-full h-40 bg-white',
'aria-label': 'Signature drawing area',
}}
/>
</div>
<button
onClick={clearSignature}
className="text-sm text-[var(--color-teal)] hover:underline"
>
Clear signature
</button>
</div>
)}
{signatureMode === 'type' && (
<div className="space-y-2">
<input
type="text"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder="Type your full name"
className="w-full p-3 border border-[var(--color-border)] rounded-lg"
aria-label="Typed signature"
/>
{typedName && (
<p
className="text-2xl p-4 border border-dashed border-[var(--color-border)] rounded-lg"
style={{ fontFamily: "'Dancing Script', cursive" }}
>
{typedName}
</p>
)}
</div>
)}
</div>
<button
onClick={handleSubmit}
disabled={!declared || isSubmitting}
className="w-full py-3 rounded-lg bg-[var(--color-teal)] text-white font-medium
disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
aria-label="Submit timesheet"
>
{isSubmitting ? 'Submitting...' : 'Submit Timesheet'}
</button>
</div>
);
}
And here is the complete actions/submitTimesheet.ts:
'use server';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
export async function submitTimesheet(formData: any) {
const session = await auth();
if (!session?.user?.id) {
throw new Error('Not authenticated');
}
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const totalHours = days.reduce((sum, day) => {
return sum + (formData[day]?.netHours || 0);
}, 0);
const timesheet = await prisma.timesheet.create({
data: {
userId: session.user.id,
weekEnding: new Date(formData.weekEnding),
status: 'SUBMITTED',
totalHours,
toilHours: Math.max(0, totalHours - (formData.contractedHours || 0)),
submittedAt: new Date(),
days: {
create: days.map((day) => ({
dayName: day,
worked: formData[day]?.worked || false,
startTime: formData[day]?.startTime || null,
finishTime: formData[day]?.finishTime || null,
breakMinutes: formData[day]?.breakMinutes || null,
netHours: formData[day]?.netHours || null,
sleepIn: formData[day]?.sleepIn || false,
onCall: formData[day]?.onCall || false,
nightSupplement: formData[day]?.nightSupplement || false,
sicknessHours: formData[day]?.sicknessHours || null,
annualLeaveHours: formData[day]?.annualLeaveHours || null,
otherLeaveHours: formData[day]?.otherLeaveHours || null,
otherLeaveType: formData[day]?.otherLeaveType || null,
})),
},
},
});
return { success: true, timesheetId: timesheet.id };
}
The timesheet wizard is complete. Care workers can now fill in every day of the week, review their hours at a glance, sign their declaration, and submit directly to their line manager. Next up, you will build the overtime hours form for workers who need to log additional project-based hours.
Build the Overtime Hours Form
Your timesheet wizard handles standard weekly hours. But care workers often pick up overtime across multiple projects, each requiring separate manager authorisation.
In this step, you'll build a dedicated overtime form with a dual-panel layout. Each panel captures hours for a different project, and the form calculates totals automatically before submission.
In this step, get ready to:
- Build the context banner, employee header, and dual-panel page layout.
- Create a reusable project panel component with daily hours, manager authorisation, and pay rate selection.
- Add the declaration section with signature, auto-calculated totals, and form submission.
Build the context banner, employee header, and page layout
The overtime form needs a new database model to store requests. You'll also create the top-of-page components that orient the user before they fill in hours.
- Add the OvertimeRequest and OvertimeDayEntry models to your Prisma schema by pasting the following at the bottom of prisma/schema.prisma:
// Overtime request submitted by a care worker
model OvertimeRequest {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
project1Reason String?
project1Service String?
project1ManagerName String?
project1ManagerSignature String?
project2Reason String?
project2Service String?
project2ManagerName String?
project2ManagerSignature String?
totalHours Float
declarationManagerName String?
declarationManagerSignature String?
status OvertimeStatus @default(PENDING)
submittedAt DateTime @default(now())
days OvertimeDayEntry[]
}
// Individual day entry within an overtime request
model OvertimeDayEntry {
id String @id @default(cuid())
overtimeRequestId String
overtimeRequest OvertimeRequest @relation(fields: [overtimeRequestId], references: [id])
dayName String
project Int
regularHours Float?
breakMinutes Int?
enhancedRate Boolean @default(false)
}
// Status tracking for overtime approvals
enum OvertimeStatus {
PENDING
APPROVED
REJECTED
}
What does this schema do?
OvertimeRequest stores top-level data for both project panels plus the declaration. Each day's hours are stored as separate OvertimeDayEntry rows linked back to the request.
The project field on each day entry is either 1 or 2, matching the dual-panel layout you'll build shortly.
- Save prisma/schema.prisma.
- Push the schema changes to your database and regenerate the Prisma Client by running:
npx prisma db push
- You should see output confirming the database is in sync and the Prisma Client has been generated.
Seeing a relation error?
If Prisma reports a relation error on the User model, make sure you added an overtimeRequests OvertimeRequest[] field to your existing User model. Every relation in Prisma needs both sides defined.
Why is my Prisma schema giving a relation error after adding OvertimeRequest?
- Create a new file components/overtime/ContextBanner.tsx and add the following code:
import { Info } from 'lucide-react';
// Displays overtime policy rules at the top of the form
export default function ContextBanner() {
return (
<div
className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
role="region"
aria-label="Overtime policy information"
>
<div className="flex items-start gap-3">
{/* Info icon for visual emphasis */}
<Info className="mt-0.5 h-5 w-5 shrink-0 text-blue-600" aria-hidden="true" />
<div className="space-y-1">
<h2 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
Overtime Policy
</h2>
<ul className="list-disc pl-4 text-sm text-blue-800 dark:text-blue-200">
<li>Maximum 16 overtime hours per week unless pre-approved.</li>
<li>Line manager authorisation required for each project.</li>
<li>Submit within 7 days of the overtime being worked.</li>
<li>Enhanced rates apply for bank holidays and night shifts only.</li>
</ul>
</div>
</div>
</div>
);
}
What does this component do?
The banner gives care workers a quick summary of overtime rules before they start filling in hours. The role="region" and aria-label make this discoverable by screen readers.
- Save components/overtime/ContextBanner.tsx.
- Create a new file components/overtime/EmployeeHeader.tsx and add the following code:
// Displays read-only employee details pulled from the session
interface EmployeeHeaderProps {
name: string;
employeeNumber: string;
service: string;
contractedHours: number;
}
export default function EmployeeHeader({
name,
employeeNumber,
service,
contractedHours,
}: EmployeeHeaderProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-3 text-sm font-semibold text-gray-500 uppercase tracking-wide">
Employee Details
</h2>
{/* Read-only fields auto-filled from session data */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p className="text-xs text-gray-500">Name</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{name}</p>
</div>
<div>
<p className="text-xs text-gray-500">Employee No.</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{employeeNumber}</p>
</div>
<div>
<p className="text-xs text-gray-500">Service</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{service}</p>
</div>
<div>
<p className="text-xs text-gray-500">Contracted Hours</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{contractedHours}h/week</p>
</div>
</div>
</div>
);
}
Why are these fields read-only?
Employee details come from the authenticated session. Pre-filling and locking them prevents data entry errors and speeds up the form for care workers who may be filling this in on their phone at the end of a shift.
- Save components/overtime/EmployeeHeader.tsx.
TypeScript error on the props?
Make sure the interface name matches exactly. EmployeeHeaderProps is defined in the same file, directly above the component function.
Help me fix a TypeScript error in my EmployeeHeader component.
Create the project panel component
Each overtime form supports two separate projects. You'll build a single reusable component that renders for each project, with its own reason, manager authorisation, daily hours table, and pay rate selection.
- Create a new file components/overtime/ProjectPanel.tsx and add the following code:
'use client';
import { useRef } from 'react';
import SignatureCanvas from 'react-signature-canvas';
// Days of the week for the hours table
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// Available services for the dropdown
const SERVICES = [
'Supported Living',
'Residential Care',
'Community Outreach',
'Day Services',
'Domiciliary Care',
'Other',
];
export interface ProjectDayData {
regularHours: number;
breakMinutes: number;
enhancedRate: boolean;
}
export interface ProjectPanelData {
reason: string;
managerName: string;
managerSignature: string;
service: string;
payRate: 'standard' | 'enhanced';
days: ProjectDayData[];
}
interface ProjectPanelProps {
project: 1 | 2;
formData: ProjectPanelData;
onChange: (data: ProjectPanelData) => void;
}
export default function ProjectPanel({ project, formData, onChange }: ProjectPanelProps) {
const sigRef = useRef<SignatureCanvas>(null);
// Calculate the subtotal hours for this panel
const subtotalHours = formData.days.reduce((sum, day) => {
const netHours = day.regularHours - day.breakMinutes / 60;
return sum + Math.max(0, netHours);
}, 0);
// Update a specific day's data
function updateDay(index: number, field: keyof ProjectDayData, value: number | boolean) {
const updatedDays = [...formData.days];
updatedDays[index] = { ...updatedDays[index], [field]: value };
onChange({ ...formData, days: updatedDays });
}
// Save the manager signature from the canvas
function handleSignatureEnd() {
if (sigRef.current) {
const dataUrl = sigRef.current.toDataURL();
onChange({ ...formData, managerSignature: dataUrl });
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
Project {project}
</h3>
{/* Reason for overtime */}
<div className="mb-4">
<label
htmlFor={`reason-${project}`}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Reason for overtime
</label>
<textarea
id={`reason-${project}`}
rows={3}
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={formData.reason}
onChange={(e) => onChange({ ...formData, reason: e.target.value })}
placeholder="Describe the overtime requirement..."
/>
</div>
{/* Manager authorisation section */}
<fieldset className="mb-4 rounded-md border border-gray-200 p-3">
<legend className="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Manager Authorisation
</legend>
<div className="mb-3">
<label
htmlFor={`manager-name-${project}`}
className="mb-1 block text-sm text-gray-600"
>
Manager name
</label>
<input
id={`manager-name-${project}`}
type="text"
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={formData.managerName}
onChange={(e) => onChange({ ...formData, managerName: e.target.value })}
/>
</div>
<div>
<p className="mb-1 text-sm text-gray-600">Manager signature</p>
<div className="rounded border border-gray-300 bg-gray-50">
<SignatureCanvas
ref={sigRef}
canvasProps={{ className: 'w-full h-24' }}
onEnd={handleSignatureEnd}
/>
</div>
<button
type="button"
className="mt-1 text-xs text-blue-600 hover:underline"
onClick={() => {
sigRef.current?.clear();
onChange({ ...formData, managerSignature: '' });
}}
>
Clear signature
</button>
</div>
</fieldset>
{/* Service selector */}
<div className="mb-4">
<label
htmlFor={`service-${project}`}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Service
</label>
<select
id={`service-${project}`}
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={formData.service}
onChange={(e) => onChange({ ...formData, service: e.target.value })}
>
<option value="">Select a service...</option>
{SERVICES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Daily overtime hours table */}
<div className="mb-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-600">
<th className="pb-2">Day</th>
<th className="pb-2">Hours</th>
<th className="pb-2">Break (min)</th>
<th className="pb-2">Enhanced</th>
</tr>
</thead>
<tbody>
{DAYS.map((day, i) => (
<tr key={day} className="border-b last:border-0">
<td className="py-2 font-medium text-gray-700 dark:text-gray-300">{day}</td>
<td className="py-2">
<input
type="number"
min={0}
max={16}
step={0.25}
className="w-16 rounded border border-gray-300 p-1 text-center"
value={formData.days[i].regularHours || ''}
onChange={(e) => updateDay(i, 'regularHours', parseFloat(e.target.value) || 0)}
aria-label={`${day} overtime hours for project ${project}`}
/>
</td>
<td className="py-2">
<input
type="number"
min={0}
max={120}
step={5}
className="w-16 rounded border border-gray-300 p-1 text-center"
value={formData.days[i].breakMinutes || ''}
onChange={(e) => updateDay(i, 'breakMinutes', parseInt(e.target.value) || 0)}
aria-label={`${day} break minutes for project ${project}`}
/>
</td>
<td className="py-2 text-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600"
checked={formData.days[i].enhancedRate}
onChange={(e) => updateDay(i, 'enhancedRate', e.target.checked)}
aria-label={`${day} enhanced rate for project ${project}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pay rate selection */}
<fieldset className="mb-4">
<legend className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Default Pay Rate
</legend>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name={`payRate-${project}`}
value="standard"
checked={formData.payRate === 'standard'}
onChange={() => onChange({ ...formData, payRate: 'standard' })}
/>
Standard
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name={`payRate-${project}`}
value="enhanced"
checked={formData.payRate === 'enhanced'}
onChange={() => onChange({ ...formData, payRate: 'enhanced' })}
/>
Enhanced
</label>
</div>
</fieldset>
{/* Panel subtotal */}
<div className="rounded-md bg-gray-100 p-3 text-right dark:bg-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-300">Panel subtotal: </span>
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">
{subtotalHours.toFixed(2)}h
</span>
</div>
</div>
);
}
What does this component do?
The ProjectPanel is a self-contained form section for one project. It captures the overtime reason, gets manager sign-off, records daily hours with break deductions, and lets the worker select standard or enhanced pay rates.
Each input uses unique IDs based on the project prop (1 or 2). This keeps labels correctly associated even when two panels render on the same page. The aria-label attributes on the table inputs ensure screen readers announce the day and project context for each field.
- Save components/overtime/ProjectPanel.tsx.
SignatureCanvas import not resolving?
Make sure you installed the signature canvas library in Step 1. If not, check that the package is in your package.json and run your install command again.
Help me fix the SignatureCanvas import in my ProjectPanel component.
Add the declaration section and wire up form submission
The declaration section sits below both project panels. It shows the combined total hours, collects the line manager's final signature, and submits the entire form through a server action.
- Create a new file components/overtime/DeclarationSection.tsx and add the following code:
'use client';
import { useRef, useState } from 'react';
import SignatureCanvas from 'react-signature-canvas';
interface DeclarationSectionProps {
totalHours: number;
onSubmit: (managerName: string, managerSignature: string) => void;
}
export default function DeclarationSection({ totalHours, onSubmit }: DeclarationSectionProps) {
const sigRef = useRef<SignatureCanvas>(null);
const [managerName, setManagerName] = useState('');
const [signature, setSignature] = useState('');
const [errors, setErrors] = useState<string[]>([]);
// Validate all required fields before submission
function handleSubmit() {
const newErrors: string[] = [];
if (!managerName.trim()) {
newErrors.push('Line manager name is required.');
}
if (!signature) {
newErrors.push('Line manager signature is required.');
}
if (totalHours <= 0) {
newErrors.push('Total overtime hours must be greater than zero.');
}
setErrors(newErrors);
if (newErrors.length === 0) {
onSubmit(managerName, signature);
}
}
// Capture signature data when drawing ends
function handleSignatureEnd() {
if (sigRef.current) {
setSignature(sigRef.current.toDataURL());
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
Declaration
</h3>
{/* Auto-calculated total hours across both panels */}
<div className="mb-4 rounded-md bg-green-50 p-4 text-center dark:bg-green-900">
<p className="text-sm text-green-700 dark:text-green-200">Total Overtime Hours</p>
<p className="text-3xl font-bold text-green-900 dark:text-green-100">
{totalHours.toFixed(2)}h
</p>
</div>
{/* Line manager print name */}
<div className="mb-4">
<label
htmlFor="declaration-manager-name"
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Line manager print name
</label>
<input
id="declaration-manager-name"
type="text"
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={managerName}
onChange={(e) => setManagerName(e.target.value)}
/>
</div>
{/* Line manager signature pad */}
<div className="mb-4">
<p className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Line manager signature
</p>
<div className="rounded border border-gray-300 bg-gray-50">
<SignatureCanvas
ref={sigRef}
canvasProps={{ className: 'w-full h-28' }}
onEnd={handleSignatureEnd}
/>
</div>
<button
type="button"
className="mt-1 text-xs text-blue-600 hover:underline"
onClick={() => {
sigRef.current?.clear();
setSignature('');
}}
>
Clear signature
</button>
</div>
{/* Accessible error messages */}
{errors.length > 0 && (
<div
role="alert"
className="mb-4 rounded-md border border-red-200 bg-red-50 p-3"
>
<ul className="list-disc pl-4 text-sm text-red-700">
{errors.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
{/* Submit button */}
<button
type="button"
onClick={handleSubmit}
className="w-full rounded-md bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
disabled={totalHours <= 0}
>
Submit Overtime Request
</button>
</div>
);
}
What does this component do?
The DeclarationSection receives the combined total from both panels and handles the final sign-off. It validates that the manager name and signature are present, then calls onSubmit with the collected data.
The error list uses role="alert" so screen readers announce validation problems immediately when they appear.
- Save components/overtime/DeclarationSection.tsx.
- Create the server action at actions/submitOvertime.ts by adding the following code:
'use server';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
// Shape of each day entry coming from the form
interface OvertimeDayInput {
dayName: string;
project: number;
regularHours: number;
breakMinutes: number;
enhancedRate: boolean;
}
// Full payload from the overtime form
interface OvertimeFormData {
project1Reason: string;
project1Service: string;
project1ManagerName: string;
project1ManagerSignature: string;
project2Reason: string;
project2Service: string;
project2ManagerName: string;
project2ManagerSignature: string;
totalHours: number;
declarationManagerName: string;
declarationManagerSignature: string;
days: OvertimeDayInput[];
}
export async function submitOvertime(data: OvertimeFormData) {
// Get the current user from the session
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' };
}
// Create the overtime request with nested day entries
const request = await prisma.overtimeRequest.create({
data: {
userId: session.user.id,
project1Reason: data.project1Reason,
project1Service: data.project1Service,
project1ManagerName: data.project1ManagerName,
project1ManagerSignature: data.project1ManagerSignature,
project2Reason: data.project2Reason,
project2Service: data.project2Service,
project2ManagerName: data.project2ManagerName,
project2ManagerSignature: data.project2ManagerSignature,
totalHours: data.totalHours,
declarationManagerName: data.declarationManagerName,
declarationManagerSignature: data.declarationManagerSignature,
days: {
create: data.days.map((day) => ({
dayName: day.dayName,
project: day.project,
regularHours: day.regularHours,
breakMinutes: day.breakMinutes,
enhancedRate: day.enhancedRate,
})),
},
},
});
return { success: true, requestId: request.id };
}
What does this server action do?
The action runs on the server, so the database write never exposes credentials to the client. It uses auth() to verify the user is logged in, then creates an OvertimeRequest record with nested OvertimeDayEntry rows in a single Prisma call.
- Save actions/submitOvertime.ts.
- Now create the page that ties all these components together. Create app/overtime/page.tsx and add the following code:
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import ContextBanner from '@/components/overtime/ContextBanner';
import EmployeeHeader from '@/components/overtime/EmployeeHeader';
import ProjectPanel, { ProjectPanelData, ProjectDayData } from '@/components/overtime/ProjectPanel';
import DeclarationSection from '@/components/overtime/DeclarationSection';
import { submitOvertime } from '@/actions/submitOvertime';
// Default state for a single project panel
const DEFAULT_DAY: ProjectDayData = { regularHours: 0, breakMinutes: 0, enhancedRate: false };
const DEFAULT_PANEL: ProjectPanelData = {
reason: '',
managerName: '',
managerSignature: '',
service: '',
payRate: 'standard',
days: Array(7).fill(null).map(() => ({ ...DEFAULT_DAY })),
};
export default function OvertimePage() {
const { data: session } = useSession();
const router = useRouter();
// State for both project panels
const [panel1, setPanel1] = useState<ProjectPanelData>(DEFAULT_PANEL);
const [panel2, setPanel2] = useState<ProjectPanelData>(DEFAULT_PANEL);
const [isSubmitting, setIsSubmitting] = useState(false);
// Calculate total hours across both panels
const totalHours = [...panel1.days, ...panel2.days].reduce((sum, day) => {
const net = day.regularHours - day.breakMinutes / 60;
return sum + Math.max(0, net);
}, 0);
// Handle form submission
async function handleSubmit(declarationManagerName: string, declarationManagerSignature: string) {
setIsSubmitting(true);
// Build day entries for both panels
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const days = [
...panel1.days.map((d, i) => ({ ...d, dayName: dayNames[i], project: 1 })),
...panel2.days.map((d, i) => ({ ...d, dayName: dayNames[i], project: 2 })),
];
const result = await submitOvertime({
project1Reason: panel1.reason,
project1Service: panel1.service,
project1ManagerName: panel1.managerName,
project1ManagerSignature: panel1.managerSignature,
project2Reason: panel2.reason,
project2Service: panel2.service,
project2ManagerName: panel2.managerName,
project2ManagerSignature: panel2.managerSignature,
totalHours,
declarationManagerName,
declarationManagerSignature,
days,
});
if (result.success) {
router.push('/dashboard');
}
setIsSubmitting(false);
}
if (!session?.user) return null;
return (
<main className="mx-auto max-w-6xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">
Overtime Hours
</h1>
{/* Policy rules banner */}
<div className="mb-6">
<ContextBanner />
</div>
{/* Auto-filled employee details from session */}
<div className="mb-6">
<EmployeeHeader
name={session.user.name || ''}
employeeNumber={session.user.employeeNumber || ''}
service={session.user.service || ''}
contractedHours={session.user.contractedHours || 0}
/>
</div>
{/* Dual project panels: side-by-side on desktop, stacked on mobile */}
<div className="mb-6 grid gap-6 lg:grid-cols-2">
<ProjectPanel project={1} formData={panel1} onChange={setPanel1} />
<ProjectPanel project={2} formData={panel2} onChange={setPanel2} />
</div>
{/* Final declaration and submit */}
<DeclarationSection totalHours={totalHours} onSubmit={handleSubmit} />
</main>
);
}
What does this page do?
The page is the orchestrator. It holds state for both project panels, calculates the combined total, and passes everything to the submitOvertime server action on submission.
The lg:grid-cols-2 class renders the two project panels side-by-side on desktop screens. On mobile, they stack vertically by default.
- Save app/overtime/page.tsx.
- Start or restart your dev server and navigate to /overtime in your browser.
- You should see the overtime policy banner at the top, your employee details auto-filled below it, and two project panels rendered side-by-side on a wide screen or stacked on a narrow one.
Page showing a blank screen or 404?
Check that the file is at app/overtime/page.tsx (not inside app/dashboard/overtime). The App Router uses the folder path as the URL.
If you see a runtime error about missing session fields, make sure your auth.ts callbacks expose employeeNumber, service, and contractedHours on the session user object.
Help me debug my overtime page not loading.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each new file created in this step.
// Overtime request submitted by a care worker
model OvertimeRequest {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
project1Reason String?
project1Service String?
project1ManagerName String?
project1ManagerSignature String?
project2Reason String?
project2Service String?
project2ManagerName String?
project2ManagerSignature String?
totalHours Float
declarationManagerName String?
declarationManagerSignature String?
status OvertimeStatus @default(PENDING)
submittedAt DateTime @default(now())
days OvertimeDayEntry[]
}
// Individual day entry within an overtime request
model OvertimeDayEntry {
id String @id @default(cuid())
overtimeRequestId String
overtimeRequest OvertimeRequest @relation(fields: [overtimeRequestId], references: [id])
dayName String
project Int
regularHours Float?
breakMinutes Int?
enhancedRate Boolean @default(false)
}
// Status tracking for overtime approvals
enum OvertimeStatus {
PENDING
APPROVED
REJECTED
}
import { Info } from 'lucide-react';
// Displays overtime policy rules at the top of the form
export default function ContextBanner() {
return (
<div
className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
role="region"
aria-label="Overtime policy information"
>
<div className="flex items-start gap-3">
{/* Info icon for visual emphasis */}
<Info className="mt-0.5 h-5 w-5 shrink-0 text-blue-600" aria-hidden="true" />
<div className="space-y-1">
<h2 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
Overtime Policy
</h2>
<ul className="list-disc pl-4 text-sm text-blue-800 dark:text-blue-200">
<li>Maximum 16 overtime hours per week unless pre-approved.</li>
<li>Line manager authorisation required for each project.</li>
<li>Submit within 7 days of the overtime being worked.</li>
<li>Enhanced rates apply for bank holidays and night shifts only.</li>
</ul>
</div>
</div>
</div>
);
}
// Displays read-only employee details pulled from the session
interface EmployeeHeaderProps {
name: string;
employeeNumber: string;
service: string;
contractedHours: number;
}
export default function EmployeeHeader({
name,
employeeNumber,
service,
contractedHours,
}: EmployeeHeaderProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-3 text-sm font-semibold text-gray-500 uppercase tracking-wide">
Employee Details
</h2>
{/* Read-only fields auto-filled from session data */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p className="text-xs text-gray-500">Name</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{name}</p>
</div>
<div>
<p className="text-xs text-gray-500">Employee No.</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{employeeNumber}</p>
</div>
<div>
<p className="text-xs text-gray-500">Service</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{service}</p>
</div>
<div>
<p className="text-xs text-gray-500">Contracted Hours</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{contractedHours}h/week</p>
</div>
</div>
</div>
);
}
'use client';
import { useRef } from 'react';
import SignatureCanvas from 'react-signature-canvas';
// Days of the week for the hours table
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// Available services for the dropdown
const SERVICES = [
'Supported Living',
'Residential Care',
'Community Outreach',
'Day Services',
'Domiciliary Care',
'Other',
];
export interface ProjectDayData {
regularHours: number;
breakMinutes: number;
enhancedRate: boolean;
}
export interface ProjectPanelData {
reason: string;
managerName: string;
managerSignature: string;
service: string;
payRate: 'standard' | 'enhanced';
days: ProjectDayData[];
}
interface ProjectPanelProps {
project: 1 | 2;
formData: ProjectPanelData;
onChange: (data: ProjectPanelData) => void;
}
export default function ProjectPanel({ project, formData, onChange }: ProjectPanelProps) {
const sigRef = useRef<SignatureCanvas>(null);
// Calculate the subtotal hours for this panel
const subtotalHours = formData.days.reduce((sum, day) => {
const netHours = day.regularHours - day.breakMinutes / 60;
return sum + Math.max(0, netHours);
}, 0);
// Update a specific day's data
function updateDay(index: number, field: keyof ProjectDayData, value: number | boolean) {
const updatedDays = [...formData.days];
updatedDays[index] = { ...updatedDays[index], [field]: value };
onChange({ ...formData, days: updatedDays });
}
// Save the manager signature from the canvas
function handleSignatureEnd() {
if (sigRef.current) {
const dataUrl = sigRef.current.toDataURL();
onChange({ ...formData, managerSignature: dataUrl });
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
Project {project}
</h3>
{/* Reason for overtime */}
<div className="mb-4">
<label
htmlFor={`reason-${project}`}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Reason for overtime
</label>
<textarea
id={`reason-${project}`}
rows={3}
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={formData.reason}
onChange={(e) => onChange({ ...formData, reason: e.target.value })}
placeholder="Describe the overtime requirement..."
/>
</div>
{/* Manager authorisation section */}
<fieldset className="mb-4 rounded-md border border-gray-200 p-3">
<legend className="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Manager Authorisation
</legend>
<div className="mb-3">
<label
htmlFor={`manager-name-${project}`}
className="mb-1 block text-sm text-gray-600"
>
Manager name
</label>
<input
id={`manager-name-${project}`}
type="text"
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={formData.managerName}
onChange={(e) => onChange({ ...formData, managerName: e.target.value })}
/>
</div>
<div>
<p className="mb-1 text-sm text-gray-600">Manager signature</p>
<div className="rounded border border-gray-300 bg-gray-50">
<SignatureCanvas
ref={sigRef}
canvasProps={{ className: 'w-full h-24' }}
onEnd={handleSignatureEnd}
/>
</div>
<button
type="button"
className="mt-1 text-xs text-blue-600 hover:underline"
onClick={() => {
sigRef.current?.clear();
onChange({ ...formData, managerSignature: '' });
}}
>
Clear signature
</button>
</div>
</fieldset>
{/* Service selector */}
<div className="mb-4">
<label
htmlFor={`service-${project}`}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Service
</label>
<select
id={`service-${project}`}
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={formData.service}
onChange={(e) => onChange({ ...formData, service: e.target.value })}
>
<option value="">Select a service...</option>
{SERVICES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Daily overtime hours table */}
<div className="mb-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-600">
<th className="pb-2">Day</th>
<th className="pb-2">Hours</th>
<th className="pb-2">Break (min)</th>
<th className="pb-2">Enhanced</th>
</tr>
</thead>
<tbody>
{DAYS.map((day, i) => (
<tr key={day} className="border-b last:border-0">
<td className="py-2 font-medium text-gray-700 dark:text-gray-300">{day}</td>
<td className="py-2">
<input
type="number"
min={0}
max={16}
step={0.25}
className="w-16 rounded border border-gray-300 p-1 text-center"
value={formData.days[i].regularHours || ''}
onChange={(e) => updateDay(i, 'regularHours', parseFloat(e.target.value) || 0)}
aria-label={`${day} overtime hours for project ${project}`}
/>
</td>
<td className="py-2">
<input
type="number"
min={0}
max={120}
step={5}
className="w-16 rounded border border-gray-300 p-1 text-center"
value={formData.days[i].breakMinutes || ''}
onChange={(e) => updateDay(i, 'breakMinutes', parseInt(e.target.value) || 0)}
aria-label={`${day} break minutes for project ${project}`}
/>
</td>
<td className="py-2 text-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600"
checked={formData.days[i].enhancedRate}
onChange={(e) => updateDay(i, 'enhancedRate', e.target.checked)}
aria-label={`${day} enhanced rate for project ${project}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pay rate selection */}
<fieldset className="mb-4">
<legend className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Default Pay Rate
</legend>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name={`payRate-${project}`}
value="standard"
checked={formData.payRate === 'standard'}
onChange={() => onChange({ ...formData, payRate: 'standard' })}
/>
Standard
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name={`payRate-${project}`}
value="enhanced"
checked={formData.payRate === 'enhanced'}
onChange={() => onChange({ ...formData, payRate: 'enhanced' })}
/>
Enhanced
</label>
</div>
</fieldset>
{/* Panel subtotal */}
<div className="rounded-md bg-gray-100 p-3 text-right dark:bg-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-300">Panel subtotal: </span>
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">
{subtotalHours.toFixed(2)}h
</span>
</div>
</div>
);
}
'use client';
import { useRef, useState } from 'react';
import SignatureCanvas from 'react-signature-canvas';
interface DeclarationSectionProps {
totalHours: number;
onSubmit: (managerName: string, managerSignature: string) => void;
}
export default function DeclarationSection({ totalHours, onSubmit }: DeclarationSectionProps) {
const sigRef = useRef<SignatureCanvas>(null);
const [managerName, setManagerName] = useState('');
const [signature, setSignature] = useState('');
const [errors, setErrors] = useState<string[]>([]);
// Validate all required fields before submission
function handleSubmit() {
const newErrors: string[] = [];
if (!managerName.trim()) {
newErrors.push('Line manager name is required.');
}
if (!signature) {
newErrors.push('Line manager signature is required.');
}
if (totalHours <= 0) {
newErrors.push('Total overtime hours must be greater than zero.');
}
setErrors(newErrors);
if (newErrors.length === 0) {
onSubmit(managerName, signature);
}
}
// Capture signature data when drawing ends
function handleSignatureEnd() {
if (sigRef.current) {
setSignature(sigRef.current.toDataURL());
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
Declaration
</h3>
{/* Auto-calculated total hours across both panels */}
<div className="mb-4 rounded-md bg-green-50 p-4 text-center dark:bg-green-900">
<p className="text-sm text-green-700 dark:text-green-200">Total Overtime Hours</p>
<p className="text-3xl font-bold text-green-900 dark:text-green-100">
{totalHours.toFixed(2)}h
</p>
</div>
{/* Line manager print name */}
<div className="mb-4">
<label
htmlFor="declaration-manager-name"
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Line manager print name
</label>
<input
id="declaration-manager-name"
type="text"
className="w-full rounded-md border border-gray-300 p-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
value={managerName}
onChange={(e) => setManagerName(e.target.value)}
/>
</div>
{/* Line manager signature pad */}
<div className="mb-4">
<p className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Line manager signature
</p>
<div className="rounded border border-gray-300 bg-gray-50">
<SignatureCanvas
ref={sigRef}
canvasProps={{ className: 'w-full h-28' }}
onEnd={handleSignatureEnd}
/>
</div>
<button
type="button"
className="mt-1 text-xs text-blue-600 hover:underline"
onClick={() => {
sigRef.current?.clear();
setSignature('');
}}
>
Clear signature
</button>
</div>
{/* Accessible error messages */}
{errors.length > 0 && (
<div
role="alert"
className="mb-4 rounded-md border border-red-200 bg-red-50 p-3"
>
<ul className="list-disc pl-4 text-sm text-red-700">
{errors.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
{/* Submit button */}
<button
type="button"
onClick={handleSubmit}
className="w-full rounded-md bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
disabled={totalHours <= 0}
>
Submit Overtime Request
</button>
</div>
);
}
'use server';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
// Shape of each day entry coming from the form
interface OvertimeDayInput {
dayName: string;
project: number;
regularHours: number;
breakMinutes: number;
enhancedRate: boolean;
}
// Full payload from the overtime form
interface OvertimeFormData {
project1Reason: string;
project1Service: string;
project1ManagerName: string;
project1ManagerSignature: string;
project2Reason: string;
project2Service: string;
project2ManagerName: string;
project2ManagerSignature: string;
totalHours: number;
declarationManagerName: string;
declarationManagerSignature: string;
days: OvertimeDayInput[];
}
export async function submitOvertime(data: OvertimeFormData) {
// Get the current user from the session
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' };
}
// Create the overtime request with nested day entries
const request = await prisma.overtimeRequest.create({
data: {
userId: session.user.id,
project1Reason: data.project1Reason,
project1Service: data.project1Service,
project1ManagerName: data.project1ManagerName,
project1ManagerSignature: data.project1ManagerSignature,
project2Reason: data.project2Reason,
project2Service: data.project2Service,
project2ManagerName: data.project2ManagerName,
project2ManagerSignature: data.project2ManagerSignature,
totalHours: data.totalHours,
declarationManagerName: data.declarationManagerName,
declarationManagerSignature: data.declarationManagerSignature,
days: {
create: data.days.map((day) => ({
dayName: day.dayName,
project: day.project,
regularHours: day.regularHours,
breakMinutes: day.breakMinutes,
enhancedRate: day.enhancedRate,
})),
},
},
});
return { success: true, requestId: request.id };
}
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import ContextBanner from '@/components/overtime/ContextBanner';
import EmployeeHeader from '@/components/overtime/EmployeeHeader';
import ProjectPanel, { ProjectPanelData, ProjectDayData } from '@/components/overtime/ProjectPanel';
import DeclarationSection from '@/components/overtime/DeclarationSection';
import { submitOvertime } from '@/actions/submitOvertime';
// Default state for a single project panel
const DEFAULT_DAY: ProjectDayData = { regularHours: 0, breakMinutes: 0, enhancedRate: false };
const DEFAULT_PANEL: ProjectPanelData = {
reason: '',
managerName: '',
managerSignature: '',
service: '',
payRate: 'standard',
days: Array(7).fill(null).map(() => ({ ...DEFAULT_DAY })),
};
export default function OvertimePage() {
const { data: session } = useSession();
const router = useRouter();
// State for both project panels
const [panel1, setPanel1] = useState<ProjectPanelData>(DEFAULT_PANEL);
const [panel2, setPanel2] = useState<ProjectPanelData>(DEFAULT_PANEL);
const [isSubmitting, setIsSubmitting] = useState(false);
// Calculate total hours across both panels
const totalHours = [...panel1.days, ...panel2.days].reduce((sum, day) => {
const net = day.regularHours - day.breakMinutes / 60;
return sum + Math.max(0, net);
}, 0);
// Handle form submission
async function handleSubmit(declarationManagerName: string, declarationManagerSignature: string) {
setIsSubmitting(true);
// Build day entries for both panels
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const days = [
...panel1.days.map((d, i) => ({ ...d, dayName: dayNames[i], project: 1 })),
...panel2.days.map((d, i) => ({ ...d, dayName: dayNames[i], project: 2 })),
];
const result = await submitOvertime({
project1Reason: panel1.reason,
project1Service: panel1.service,
project1ManagerName: panel1.managerName,
project1ManagerSignature: panel1.managerSignature,
project2Reason: panel2.reason,
project2Service: panel2.service,
project2ManagerName: panel2.managerName,
project2ManagerSignature: panel2.managerSignature,
totalHours,
declarationManagerName,
declarationManagerSignature,
days,
});
if (result.success) {
router.push('/dashboard');
}
setIsSubmitting(false);
}
if (!session?.user) return null;
return (
<main className="mx-auto max-w-6xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">
Overtime Hours
</h1>
{/* Policy rules banner */}
<div className="mb-6">
<ContextBanner />
</div>
{/* Auto-filled employee details from session */}
<div className="mb-6">
<EmployeeHeader
name={session.user.name || ''}
employeeNumber={session.user.employeeNumber || ''}
service={session.user.service || ''}
contractedHours={session.user.contractedHours || 0}
/>
</div>
{/* Dual project panels: side-by-side on desktop, stacked on mobile */}
<div className="mb-6 grid gap-6 lg:grid-cols-2">
<ProjectPanel project={1} formData={panel1} onChange={setPanel1} />
<ProjectPanel project={2} formData={panel2} onChange={setPanel2} />
</div>
{/* Final declaration and submit */}
<DeclarationSection totalHours={totalHours} onSubmit={handleSubmit} />
</main>
);
}
Your overtime form is complete with dual-panel layout, manager authorisation, and server-side submission. Next up, you'll build the annual leave booking and sickness reporting pages.
Build Annual Leave and Sickness Reporting
Your overtime form is complete and care workers can record extra hours. Now you need to build two more essential pages that every staff member uses regularly: annual leave booking and sickness reporting.
These pages have a different design challenge. Annual leave needs precise date calculations and a live balance tracker. Sickness reporting needs empathy and warmth, because a care worker filling in this form is already having a difficult day.
In this step, get ready to:
- Build the Annual Leave page with a balance banner, request form, and leave history table.
- Create the Sickness Reporting page with an empathetic banner, manager call instruction, and sickness form.
- Add a shared calendar component showing booked leave dates and team availability.
Build the Annual Leave page
The annual leave page lets care workers see their remaining leave balance, submit new requests, and review past bookings. It needs a progress bar showing days used, a form with automatic working-day calculations, and a paginated history table.
Start by adding the LeaveRequest model and LeaveStatus enum to your Prisma schema.
- Open prisma/schema.prisma and add the following model and enum at the bottom of the file:
// Annual leave request submitted by a care worker
model LeaveRequest {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
startDate DateTime
endDate DateTime
workingDays Float
notes String?
status LeaveStatus @default(PENDING)
submittedAt DateTime @default(now())
}
// Possible states for a leave request
enum LeaveStatus {
PENDING
APPROVED
REJECTED
}
What does this model do?
The LeaveRequest model stores each leave booking with start/end dates, the computed working days (excluding weekends), optional notes, and a status that defaults to PENDING.
The LeaveStatus enum uses the same pattern as OvertimeStatus from the previous step. Line managers will approve or reject requests later.
- Save prisma/schema.prisma.
- Push the schema changes to your database by running:
npx prisma db push
Seeing a relation error?
Make sure you have added a leaveRequests LeaveRequest[] field on your User model. Prisma requires both sides of a one-to-many relation to be defined.
help me fix my Prisma relation error for LeaveRequest
Next, create the server action that handles leave request submissions.
- Create a new file at actions/submitLeaveRequest.ts and add the following code:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// Server action to create a new leave request
export async function submitLeaveRequest(data: {
startDate: string
endDate: string
workingDays: number
notes?: string
}) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// Create the leave request record in the database
await prisma.leaveRequest.create({
data: {
userId: session.user.id,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
workingDays: data.workingDays,
notes: data.notes || null,
},
})
// Refresh the page to show the new request
revalidatePath('/annual-leave')
return { success: true }
}
What does this action do?
The action authenticates the user via auth(), creates a LeaveRequest record with the submitted dates and working days, then calls revalidatePath to refresh the cached page data.
- Save actions/submitLeaveRequest.ts.
Now build the three leave components. Start with the balance banner.
- Create components/leave/LeaveBalanceBanner.tsx with the following code:
// Displays the care worker's leave balance as a progress bar
export default function LeaveBalanceBanner({
daysTaken,
totalEntitlement,
}: {
daysTaken: number
totalEntitlement: number
}) {
const remaining = totalEntitlement - daysTaken
const percentage = (daysTaken / totalEntitlement) * 100
return (
<div className="rounded-xl bg-white p-6 shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Annual Leave Balance
</h2>
{/* Progress bar showing days used */}
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden mb-3">
<div
className="h-full bg-teal-500 rounded-full transition-all duration-500"
style={{ width: `${Math.min(percentage, 100)}%` }}
role="progressbar"
aria-valuenow={daysTaken}
aria-valuemin={0}
aria-valuemax={totalEntitlement}
aria-label={`${daysTaken} of ${totalEntitlement} days used`}
/>
</div>
{/* Labels showing taken and remaining */}
<div className="flex justify-between text-sm">
<span className="text-gray-600">
{daysTaken} days taken
</span>
<span className="font-medium text-teal-700">
{remaining} days remaining
</span>
</div>
</div>
)
}
What does this component do?
It receives daysTaken and totalEntitlement as props, calculates the percentage used, and renders an accessible progress bar with ARIA attributes. The teal colour matches the design system.
- Save components/leave/LeaveBalanceBanner.tsx.
- Create components/leave/LeaveRequestForm.tsx with the following code:
'use client'
import { useState } from 'react'
import { submitLeaveRequest } from '@/actions/submitLeaveRequest'
// Calculates working days between two dates (excludes weekends)
function countWorkingDays(start: Date, end: Date): number {
let count = 0
const current = new Date(start)
while (current <= end) {
const day = current.getDay()
// 0 = Sunday, 6 = Saturday
if (day !== 0 && day !== 6) {
count++
}
current.setDate(current.getDate() + 1)
}
return count
}
export default function LeaveRequestForm({
daysTaken,
totalEntitlement,
}: {
daysTaken: number
totalEntitlement: number
}) {
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
// Compute working days whenever dates change
const workingDays =
startDate && endDate
? countWorkingDays(new Date(startDate), new Date(endDate))
: 0
// Live remaining balance after this request
const remainingBalance = totalEntitlement - daysTaken - workingDays
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
await submitLeaveRequest({
startDate,
endDate,
workingDays,
notes: notes || undefined,
})
// Reset form after successful submission
setStartDate('')
setEndDate('')
setNotes('')
setSubmitting(false)
}
return (
<form onSubmit={handleSubmit} className="rounded-xl bg-white p-6 shadow-sm border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Request Leave
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Start date picker */}
<div>
<label htmlFor="start-date" className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
{/* End date picker */}
<div>
<label htmlFor="end-date" className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
min={startDate}
className="w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
</div>
{/* Duration calculator and remaining balance */}
{workingDays > 0 && (
<div className="flex gap-4 mb-4 text-sm">
<span className="text-gray-600">
Duration: <strong>{workingDays}</strong> working days
</span>
<span className={remainingBalance >= 0 ? 'text-teal-700' : 'text-red-600'}>
Remaining after this request: <strong>{remainingBalance}</strong> days
</span>
</div>
)}
{/* Notes textarea */}
<div className="mb-4">
<label htmlFor="leave-notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<textarea
id="leave-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2"
placeholder="Any additional information for your manager"
/>
</div>
<button
type="submit"
disabled={submitting || workingDays === 0 || remainingBalance < 0}
className="w-full rounded-lg bg-teal-600 px-4 py-2 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Submitting...' : 'Submit Leave Request'}
</button>
</form>
)
}
What does this component do?
This is a client component that manages form state for start date, end date, and notes. The countWorkingDays function loops through the date range and excludes Saturdays and Sundays.
The remaining balance updates live as the user selects dates. If the request would exceed available days, the submit button disables automatically.
- Save components/leave/LeaveRequestForm.tsx.
- Create components/leave/LeaveHistoryTable.tsx with the following code:
'use client'
import { useState } from 'react'
interface LeaveRequestRow {
id: string
startDate: string
endDate: string
workingDays: number
status: 'PENDING' | 'APPROVED' | 'REJECTED'
submittedAt: string
}
// Status badge colours: green for approved, amber for pending, red for rejected
const statusStyles = {
APPROVED: 'bg-green-100 text-green-800',
PENDING: 'bg-amber-100 text-amber-800',
REJECTED: 'bg-red-100 text-red-800',
}
const PAGE_SIZE = 10
export default function LeaveHistoryTable({
requests,
}: {
requests: LeaveRequestRow[]
}) {
const [page, setPage] = useState(0)
const totalPages = Math.ceil(requests.length / PAGE_SIZE)
const pageData = requests.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
return (
<div className="rounded-xl bg-white p-6 shadow-sm border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Leave History
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left">
<th className="py-2 pr-4 font-medium text-gray-600">Period</th>
<th className="py-2 pr-4 font-medium text-gray-600">Working Days</th>
<th className="py-2 pr-4 font-medium text-gray-600">Status</th>
<th className="py-2 font-medium text-gray-600">Submitted</th>
</tr>
</thead>
<tbody>
{pageData.map((req) => (
<tr key={req.id} className="border-b border-gray-50">
<td className="py-3 pr-4">
{new Date(req.startDate).toLocaleDateString()} –{' '}
{new Date(req.endDate).toLocaleDateString()}
</td>
<td className="py-3 pr-4">{req.workingDays}</td>
<td className="py-3 pr-4">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusStyles[req.status]}`}>
{req.status.charAt(0) + req.status.slice(1).toLowerCase()}
</span>
</td>
<td className="py-3">
{new Date(req.submittedAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination controls */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-4">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="text-sm text-teal-600 disabled:text-gray-400"
>
Previous
</button>
<span className="text-sm text-gray-500">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page === totalPages - 1}
className="text-sm text-teal-600 disabled:text-gray-400"
>
Next
</button>
</div>
)}
</div>
)
}
What does this component do?
It renders a paginated table of past leave requests. Each row shows the date period, number of working days, a colour-coded status badge (green for Approved, amber for Pending, red for Rejected), and the submission date. Pagination is set to 10 rows per page.
- Save components/leave/LeaveHistoryTable.tsx.
Finally, wire everything together in the Annual Leave page.
- Create app/annual-leave/page.tsx with the following code:
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import LeaveBalanceBanner from '@/components/leave/LeaveBalanceBanner'
import LeaveRequestForm from '@/components/leave/LeaveRequestForm'
import LeaveHistoryTable from '@/components/leave/LeaveHistoryTable'
import MiniCalendar from '@/components/shared/MiniCalendar'
import { redirect } from 'next/navigation'
// Server component fetching user's leave balance and history
export default async function AnnualLeavePage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
// Fetch leave requests for this user
const leaveRequests = await prisma.leaveRequest.findMany({
where: { userId: session.user.id },
orderBy: { submittedAt: 'desc' },
})
// Calculate leave balance (assuming 28 days entitlement for UK care workers)
const totalEntitlement = 28
const daysTaken = leaveRequests
.filter((r) => r.status === 'APPROVED')
.reduce((sum, r) => sum + r.workingDays, 0)
// Format requests for the history table
const historyData = leaveRequests.map((r) => ({
id: r.id,
startDate: r.startDate.toISOString(),
endDate: r.endDate.toISOString(),
workingDays: r.workingDays,
status: r.status as 'PENDING' | 'APPROVED' | 'REJECTED',
submittedAt: r.submittedAt.toISOString(),
}))
// Get approved leave dates for the calendar
const bookedDates = leaveRequests
.filter((r) => r.status === 'APPROVED')
.map((r) => ({ start: r.startDate.toISOString(), end: r.endDate.toISOString() }))
return (
<main className="max-w-4xl mx-auto px-4 py-8 space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Annual Leave</h1>
<LeaveBalanceBanner
daysTaken={daysTaken}
totalEntitlement={totalEntitlement}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<LeaveRequestForm
daysTaken={daysTaken}
totalEntitlement={totalEntitlement}
/>
<MiniCalendar bookedDates={bookedDates} />
</div>
<LeaveHistoryTable requests={historyData} />
</main>
)
}
What does this page do?
This is a Server Component that fetches the authenticated user's leave requests from the database. It calculates days taken (only counting approved requests), formats the data for child components, and renders the full page layout.
- Save app/annual-leave/page.tsx.
- Refresh your browser and navigate to the Annual Leave page. You should see the balance banner with a teal progress bar, the leave request form with date pickers, and an empty history table.
Page not loading?
Make sure you ran npx prisma db push after adding the LeaveRequest model. If the Prisma client is out of date, run npx prisma generate to regenerate it.
help me debug my Annual Leave page not loading
Create the Sickness Reporting page
Sickness reporting is sensitive. A care worker filling in this form is unwell, possibly stressed, and needs to feel supported. The page uses warm language, an empathetic banner, and a clear instruction to call their manager first.
- Open prisma/schema.prisma and add the SicknessReport model at the bottom:
// Records a sickness absence report from a care worker
model SicknessReport {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
absenceDate DateTime
shiftStart String
shiftFinish String
reason String
managerNotified Boolean
notes String?
submittedAt DateTime @default(now())
}
Why are shift times stored as strings?
Care workers enter times like 07:00 and 15:00 for their scheduled shift. Storing these as String fields avoids timezone conversion issues. The absenceDate is a full DateTime for the calendar date of the absence.
- Save prisma/schema.prisma and push the schema changes by running:
npx prisma db push
Now create the server action for sickness reporting.
- Create actions/submitSicknessReport.ts with the following code:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// Server action to record a sickness absence
export async function submitSicknessReport(data: {
absenceDate: string
shiftStart: string
shiftFinish: string
reason: string
managerNotified: boolean
notes?: string
}) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// Create the sickness report record
await prisma.sicknessReport.create({
data: {
userId: session.user.id,
absenceDate: new Date(data.absenceDate),
shiftStart: data.shiftStart,
shiftFinish: data.shiftFinish,
reason: data.reason,
managerNotified: data.managerNotified,
notes: data.notes || null,
},
})
revalidatePath('/sickness')
return { success: true }
}
- Save actions/submitSicknessReport.ts.
Build the three sickness page components. The empathy banner sets the tone.
- Create components/sickness/EmpathyBanner.tsx with the following code:
import { Heart } from 'lucide-react'
// Warm supportive message displayed at the top of the sickness page
export default function EmpathyBanner() {
return (
<div className="rounded-xl bg-rose-50 border border-rose-100 p-5 flex items-start gap-4">
<Heart className="w-6 h-6 text-rose-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-gray-900 font-medium">
We hope you feel better soon.
</p>
<p className="text-gray-600 text-sm mt-1">
This form helps us support you. Take your time completing it.
</p>
</div>
</div>
)
}
What does this component do?
It renders a soft rose-coloured banner with a heart icon and a reassuring message. The tone is deliberately warm and non-transactional, matching the empathetic design principles for healthcare software.
- Save components/sickness/EmpathyBanner.tsx.
- Create components/sickness/ManagerCallBox.tsx with the following code:
import { Phone } from 'lucide-react'
// Instruction box reminding care workers to call their manager first
export default function ManagerCallBox() {
return (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-5 flex items-start gap-4">
<Phone className="w-6 h-6 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-gray-900 font-medium">
Please call your manager before completing this form.
</p>
<p className="text-gray-600 text-sm mt-1">
Your manager needs to know as soon as possible so cover can be arranged for your shift.
</p>
</div>
</div>
)
}
- Save components/sickness/ManagerCallBox.tsx.
- Create components/sickness/SicknessForm.tsx with the following code:
'use client'
import { useState } from 'react'
import { submitSicknessReport } from '@/actions/submitSicknessReport'
// Sickness absence form with empathetic field labels
export default function SicknessForm({
onSubmitted,
}: {
onSubmitted: () => void
}) {
const [absenceDate, setAbsenceDate] = useState('')
const [shiftStart, setShiftStart] = useState('')
const [shiftFinish, setShiftFinish] = useState('')
const [reason, setReason] = useState('')
const [managerNotified, setManagerNotified] = useState<boolean | null>(null)
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (managerNotified === null) return
setSubmitting(true)
await submitSicknessReport({
absenceDate,
shiftStart,
shiftFinish,
reason,
managerNotified,
notes: notes || undefined,
})
setSubmitting(false)
onSubmitted()
}
return (
<form onSubmit={handleSubmit} className="rounded-xl bg-white p-6 shadow-sm border border-gray-100 space-y-4">
{/* Absence date */}
<div>
<label htmlFor="absence-date" className="block text-sm font-medium text-gray-700 mb-1">
Date of absence
</label>
<input
id="absence-date"
type="date"
value={absenceDate}
onChange={(e) => setAbsenceDate(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
{/* Shift times */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="shift-start" className="block text-sm font-medium text-gray-700 mb-1">
Shift start time
</label>
<input
id="shift-start"
type="time"
value={shiftStart}
onChange={(e) => setShiftStart(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="shift-finish" className="block text-sm font-medium text-gray-700 mb-1">
Shift finish time
</label>
<input
id="shift-finish"
type="time"
value={shiftFinish}
onChange={(e) => setShiftFinish(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
</div>
{/* Reason dropdown */}
<div>
<label htmlFor="sickness-reason" className="block text-sm font-medium text-gray-700 mb-1">
Reason for absence
</label>
<select
id="sickness-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2"
>
<option value="">Select a reason</option>
<option value="Illness">Illness</option>
<option value="Injury">Injury</option>
<option value="Mental Health">Mental Health</option>
<option value="Family Emergency">Family Emergency</option>
<option value="Prefer not to say">Prefer not to say</option>
<option value="Other">Other</option>
</select>
</div>
{/* Manager notification status */}
<fieldset>
<legend className="text-sm font-medium text-gray-700 mb-2">
Have you notified your manager?
</legend>
<div className="flex gap-6">
<label className="flex items-center gap-2">
<input
type="radio"
name="manager-notified"
checked={managerNotified === true}
onChange={() => setManagerNotified(true)}
className="w-4 h-4 text-teal-600"
/>
<span className="text-sm text-gray-700">Yes</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="manager-notified"
checked={managerNotified === false}
onChange={() => setManagerNotified(false)}
className="w-4 h-4 text-teal-600"
/>
<span className="text-sm text-gray-700">No</span>
</label>
</div>
</fieldset>
{/* Notes textarea */}
<div>
<label htmlFor="sickness-notes" className="block text-sm font-medium text-gray-700 mb-1">
Additional notes (optional)
</label>
<textarea
id="sickness-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2"
placeholder="Anything else you would like us to know"
/>
</div>
<button
type="submit"
disabled={submitting || !reason || managerNotified === null}
className="w-full rounded-lg bg-teal-600 px-4 py-2 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Submitting...' : 'Submit Sickness Report'}
</button>
</form>
)
}
What does this form include?
The form captures the absence date, scheduled shift start/finish times, a reason dropdown (including a compassionate "Prefer not to say" option), a radio group for manager notification status, and optional notes.
On submit it calls the submitSicknessReport server action, then triggers the onSubmitted callback to flip the page into its confirmation state.
- Save components/sickness/SicknessForm.tsx.
Now assemble the sickness page that ties these components together.
- Create app/sickness/page.tsx with the following code:
'use client'
import { useState } from 'react'
import EmpathyBanner from '@/components/sickness/EmpathyBanner'
import ManagerCallBox from '@/components/sickness/ManagerCallBox'
import SicknessForm from '@/components/sickness/SicknessForm'
import { CheckCircle } from 'lucide-react'
// Client component toggling between form and confirmation
export default function SicknessPage() {
const [submitted, setSubmitted] = useState(false)
return (
<main className="max-w-2xl mx-auto px-4 py-8 space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Report Sickness</h1>
<EmpathyBanner />
<ManagerCallBox />
{!submitted ? (
<SicknessForm onSubmitted={() => setSubmitted(true)} />
) : (
// Post-submit confirmation with supportive message
<div className="rounded-xl bg-white p-8 shadow-sm border border-gray-100 text-center">
<CheckCircle className="w-12 h-12 text-teal-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Report submitted
</h2>
<p className="text-gray-600">
Take care of yourself. Your manager has been notified and will be in touch if they need anything else.
</p>
</div>
)}
</main>
)
}
What does this page do?
The page uses a submitted state toggle. Before submission it shows the empathy banner, the manager call instruction, and the sickness form. After submission it replaces the form with a warm confirmation card featuring a check icon and a supportive message.
- Save app/sickness/page.tsx.
- Navigate to the sickness page in your browser. You should see the rose-coloured empathy banner, the amber manager call box, and the sickness form with a reason dropdown that includes the "Prefer not to say" option.
Form not submitting?
Make sure you have selected a reason from the dropdown AND chosen Yes or No for the manager notification radio buttons. The submit button stays disabled until both are filled.
help me debug my sickness form submission
Add the shared calendar component
The annual leave page already references a MiniCalendar component. This shared component shows a month-view grid with booked leave dates highlighted in teal and team availability in light teal.
- Create components/shared/MiniCalendar.tsx with the following code:
'use client'
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface BookedRange {
start: string
end: string
}
// Checks if a date falls within any booked range
function isDateBooked(date: Date, ranges: BookedRange[]): boolean {
return ranges.some((r) => {
const start = new Date(r.start)
const end = new Date(r.end)
return date >= start && date <= end
})
}
// Generates all days in a month grid (includes leading/trailing empty slots)
function getMonthDays(year: number, month: number): (Date | null)[] {
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const days: (Date | null)[] = []
// Leading empty slots for days before the first of the month
for (let i = 0; i < firstDay.getDay(); i++) {
days.push(null)
}
// Actual days of the month
for (let d = 1; d <= lastDay.getDate(); d++) {
days.push(new Date(year, month, d))
}
return days
}
export default function MiniCalendar({
bookedDates = [],
teamDates = [],
}: {
bookedDates?: BookedRange[]
teamDates?: BookedRange[]
}) {
const [currentMonth, setCurrentMonth] = useState(new Date())
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
const days = getMonthDays(year, month)
const monthName = currentMonth.toLocaleDateString('en-GB', {
month: 'long',
year: 'numeric',
})
function prevMonth() {
setCurrentMonth(new Date(year, month - 1, 1))
}
function nextMonth() {
setCurrentMonth(new Date(year, month + 1, 1))
}
return (
<div className="rounded-xl bg-white p-6 shadow-sm border border-gray-100">
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prevMonth}
aria-label="Previous month"
className="p-1 rounded hover:bg-gray-100"
>
<ChevronLeft className="w-5 h-5 text-gray-600" />
</button>
<span className="font-medium text-gray-900">{monthName}</span>
<button
onClick={nextMonth}
aria-label="Next month"
className="p-1 rounded hover:bg-gray-100"
>
<ChevronRight className="w-5 h-5 text-gray-600" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 text-center text-xs font-medium text-gray-500 mb-2">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<span key={d}>{d}</span>
))}
</div>
{/* Date grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((date, i) => {
if (!date) {
return <div key={`empty-${i}`} />
}
const isBooked = isDateBooked(date, bookedDates)
const isTeam = !isBooked && isDateBooked(date, teamDates)
let cellClass = 'w-8 h-8 flex items-center justify-center rounded-full text-sm'
if (isBooked) {
cellClass += ' bg-teal-500 text-white font-medium'
} else if (isTeam) {
cellClass += ' bg-teal-100 text-teal-800'
} else {
cellClass += ' text-gray-700 hover:bg-gray-50'
}
return (
<div
key={date.toISOString()}
className={cellClass}
aria-label={`${date.toLocaleDateString('en-GB')}${isBooked ? ', booked' : ''}${isTeam ? ', team leave' : ''}`}
>
{date.getDate()}
</div>
)
})}
</div>
</div>
)
}
What does this component do?
The MiniCalendar renders a month grid with previous/next navigation buttons. It highlights the user's booked leave dates in solid teal and team leave dates in a lighter teal background. Each date cell has an aria-label for screen reader accessibility.
- Save components/shared/MiniCalendar.tsx.
- Navigate back to the Annual Leave page and refresh your browser. You should now see the calendar component beside the leave request form, with month navigation arrows and day cells.
Calendar not appearing?
Check that the import path in app/annual-leave/page.tsx matches the file location exactly: @/components/shared/MiniCalendar. The shared folder must exist inside components/.
help me fix my MiniCalendar import
Your annual leave and sickness pages are complete. Care workers can now book time off with live balance calculations and report sickness with a compassionate, supportive experience. Next up, you will build the line manager dashboard where managers review and approve these submissions.
Build the Line Manager Dashboard
Every timesheet a care worker submits needs to reach their line manager for review and approval. Without this workflow, payroll cannot process hours accurately.
In this step, you will build the line manager dashboard. This is a completely separate view restricted to users with the line_manager role, showing all staff timesheets in one place with sorting, filtering, and a full approval workflow.
In this step, get ready to:
- Build the manager overview banner with colour-coded status metric cards.
- Create a sortable, filterable staff timesheet table with action buttons.
- Build the timesheet approval panel with TOIL fields, manager actions, and accessible confirmation modals.
Build the manager overview banner with status summary metrics
The line manager dashboard page is a Server Component that checks the user's role before rendering. If a care worker somehow navigates to this URL, they are redirected to the login page.
- Create the folder app/manager/ inside your project.
- Create a new file page.tsx inside app/manager/ and add the following code:
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import OverviewBanner from '@/components/manager/OverviewBanner'
import StaffTimesheetTable from '@/components/manager/StaffTimesheetTable'
export default async function ManagerPage() {
// Check session and enforce line manager role
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
redirect('/login')
}
// Fetch all timesheets for staff in the manager's service
const timesheets = await prisma.timesheet.findMany({
where: { user: { service: session.user.service } },
include: { user: true },
orderBy: { weekEnding: 'desc' },
})
// Compute status counts for the overview banner
const counts = {
approved: timesheets.filter((t) => t.status === 'APPROVED').length,
pending: timesheets.filter((t) => t.status === 'PENDING').length,
notSubmitted: timesheets.filter((t) => t.status === 'NOT_SUBMITTED').length,
sickness: timesheets.filter((t) => t.status === 'SICKNESS').length,
}
return (
<main className="p-6 space-y-8">
<OverviewBanner
managerName={session.user.name}
service={session.user.service}
counts={counts}
/>
<StaffTimesheetTable timesheets={timesheets} />
</main>
)
}
What does this code do?
The page calls auth() and immediately redirects non-managers away. This is a secondary defence layer on top of the route-level middleware you already configured.
It then queries Prisma for all timesheets belonging to staff in the same service as the manager. The computed counts drive the four metric cards in the banner.
Next, you need the banner component that displays the manager's greeting and the four metric cards.
- Create the folder components/manager/ inside your project.
- Create a new file OverviewBanner.tsx inside components/manager/ and add this code:
import { CheckCircle, Clock, AlertTriangle, Heart } from 'lucide-react'
interface OverviewBannerProps {
managerName: string
service: string
counts: {
approved: number
pending: number
notSubmitted: number
sickness: number
}
}
// Each metric card maps to a timesheet status
const metricConfig = [
{ key: 'approved', label: 'Approved', colour: 'bg-green-100 text-green-800', icon: CheckCircle },
{ key: 'pending', label: 'Pending', colour: 'bg-amber-100 text-amber-800', icon: Clock },
{ key: 'notSubmitted', label: 'Not Submitted', colour: 'bg-red-100 text-red-800', icon: AlertTriangle },
{ key: 'sickness', label: 'Sickness', colour: 'bg-blue-100 text-blue-800', icon: Heart },
] as const
export default function OverviewBanner({ managerName, service, counts }: OverviewBannerProps) {
return (
<section aria-label="Manager overview">
{/* Greeting and service label */}
<h1 className="text-2xl font-semibold text-cs-dark">
Welcome back, {managerName}
</h1>
<p className="text-cs-muted mt-1">{service}</p>
{/* Four colour-coded metric cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
{metricConfig.map(({ key, label, colour, icon: Icon }) => (
<div
key={key}
className={`rounded-lg p-4 ${colour} flex items-center gap-3`}
>
<Icon className="w-6 h-6" aria-hidden="true" />
<div>
<p className="text-2xl font-bold">{counts[key as keyof typeof counts]}</p>
<p className="text-sm">{label}</p>
</div>
</div>
))}
</div>
</section>
)
}
What does this code do?
The OverviewBanner renders the manager's name, their service, and four metric cards in a responsive grid. Each card uses a distinct background colour to convey status at a glance: green for Approved, amber for Pending, red for Not Submitted, and blue for Sickness.
Icons are marked aria-hidden="true" because the adjacent text label already communicates the meaning. This follows the triple-redundancy principle: colour plus icon plus text.
- Save both app/manager/page.tsx and components/manager/OverviewBanner.tsx.
- Log in as a line manager and navigate to /manager. You should see the welcome greeting and four coloured metric cards showing counts.
Page redirects you to login?
Make sure you are logged in with a user whose role is line_manager in the database. If the role field does not match exactly, the redirect fires.
Also check that your auth.ts JWT callback is propagating role and service onto the session object. Help me debug my line manager redirect issue
Create the sortable, filterable staff timesheet table
The manager needs to quickly scan all submitted timesheets and take action. A table with sort and filter controls lets them find outstanding submissions without scrolling through dozens of records.
- Create a new file StaffTimesheetTable.tsx inside components/manager/ and add the following code:
'use client'
import { useState } from 'react'
import TimesheetApprovalPanel from './TimesheetApprovalPanel'
interface Timesheet {
id: string
weekEnding: string
totalHours: number
status: string
user: { name: string; employeeNumber: string }
}
interface StaffTimesheetTableProps {
timesheets: Timesheet[]
}
type SortField = 'name' | 'status' | 'hours'
export default function StaffTimesheetTable({ timesheets }: StaffTimesheetTableProps) {
// State for sort, filter, and which timesheet is open for review
const [sortField, setSortField] = useState<SortField>('name')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const [filterStatus, setFilterStatus] = useState<string>('all')
const [selectedTimesheetId, setSelectedTimesheetId] = useState<string | null>(null)
// Apply filter
const filtered = filterStatus === 'all'
? timesheets
: timesheets.filter((t) => t.status === filterStatus)
// Apply sort
const sorted = [...filtered].sort((a, b) => {
let compare = 0
if (sortField === 'name') compare = a.user.name.localeCompare(b.user.name)
if (sortField === 'status') compare = a.status.localeCompare(b.status)
if (sortField === 'hours') compare = a.totalHours - b.totalHours
return sortOrder === 'asc' ? compare : -compare
})
// Toggle sort direction when clicking the same column
function handleSort(field: SortField) {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortOrder('asc')
}
}
// Status badge colour mapping
function badgeClass(status: string) {
switch (status) {
case 'APPROVED': return 'bg-green-100 text-green-800'
case 'PENDING': return 'bg-amber-100 text-amber-800'
case 'NOT_SUBMITTED': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
return (
<section aria-label="Staff timesheets">
{/* Filter dropdown */}
<div className="flex items-center gap-4 mb-4">
<label htmlFor="status-filter" className="text-sm font-medium">
Filter by status:
</label>
<select
id="status-filter"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-md border border-cs-border px-3 py-2 text-sm"
>
<option value="all">All</option>
<option value="APPROVED">Approved</option>
<option value="PENDING">Pending</option>
<option value="NOT_SUBMITTED">Not Submitted</option>
<option value="SICKNESS">Sickness</option>
</select>
</div>
{/* Sortable table */}
<div className="overflow-x-auto">
<table className="w-full text-sm" role="table">
<thead>
<tr className="border-b border-cs-border">
<th className="text-left p-3 cursor-pointer" onClick={() => handleSort('name')}>
Employee {sortField === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-3">Week Ending</th>
<th className="text-left p-3 cursor-pointer" onClick={() => handleSort('hours')}>
Hours {sortField === 'hours' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-3 cursor-pointer" onClick={() => handleSort('status')}>
Status {sortField === 'status' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-3">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((t) => (
<tr key={t.id} className="border-b border-cs-border hover:bg-cs-surface">
<td className="p-3">{t.user.name}</td>
<td className="p-3">{t.weekEnding}</td>
<td className="p-3">{t.totalHours}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${badgeClass(t.status)}`}>
{t.status.replace('_', ' ')}
</span>
</td>
<td className="p-3 flex gap-2">
<button
onClick={() => setSelectedTimesheetId(t.id)}
className="text-cs-primary underline text-sm"
>
Review
</button>
<button className="text-cs-muted underline text-sm">View</button>
<button className="text-amber-600 underline text-sm">Chase</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Approval panel opens when Review is clicked */}
{selectedTimesheetId && (
<TimesheetApprovalPanel
timesheetId={selectedTimesheetId}
onClose={() => setSelectedTimesheetId(null)}
/>
)}
</section>
)
}
What does this code do?
This client component manages local state for sorting, filtering, and selecting a timesheet. The filter dropdown narrows the visible rows by status. Clicking a column header toggles sort direction.
Each row shows the employee name, week-ending date, total hours, a colour-coded status badge, and three action buttons. Clicking Review sets selectedTimesheetId which opens the TimesheetApprovalPanel.
- Save components/manager/StaffTimesheetTable.tsx.
- Refresh the /manager page. You should see a table of staff timesheets with a filter dropdown and clickable column headers for sorting.
Table is empty?
You need at least one timesheet record in your database with a user whose service matches the logged-in manager's service. Seed test data using Prisma Studio or a seed script.
Help me seed test timesheet data for my manager dashboard
Build the approval panel, TOIL fields, and confirmation modals
When a manager clicks Review, a panel slides in showing the full timesheet details. The manager can approve, request an amendment, or reject the timesheet. Approve and Request Amendment open a confirmation modal. Reject requires a reason before confirming.
- Create a new file ConfirmModal.tsx inside components/ui/ and add this reusable modal component:
'use client'
import { useEffect, useRef } from 'react'
interface ConfirmModalProps {
isOpen: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmModal({ isOpen, title, message, onConfirm, onCancel }: ConfirmModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
// Focus trap: move focus into the modal when it opens
useEffect(() => {
if (isOpen && cancelRef.current) {
cancelRef.current.focus()
}
}, [isOpen])
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen) {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onCancel])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full mx-4"
>
<h2 id="confirm-modal-title" className="text-lg font-semibold mb-2">
{title}
</h2>
<p className="text-sm text-cs-muted mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 rounded-md border border-cs-border text-sm"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 rounded-md bg-cs-primary text-white text-sm"
>
Confirm
</button>
</div>
</div>
</div>
)
}
Why focus the Cancel button?
The WAI-ARIA dialog pattern recommends focusing the least destructive action when a dialog involves an irreversible operation. Approving or rejecting a timesheet affects payroll, so focusing Cancel prevents accidental confirmation from a stray Enter key press.
Now create the server actions that handle each manager decision.
- Create a new file actions/approveTimesheet.ts and add:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function approveTimesheet(timesheetId: string, toilHours: number, toilExpiry: string) {
// Re-verify the caller is a line manager
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
throw new Error('Unauthorized')
}
// Update timesheet status and record TOIL hours
await prisma.timesheet.update({
where: { id: timesheetId },
data: {
status: 'APPROVED',
toilHours,
toilExpiry: new Date(toilExpiry),
},
})
return { success: true }
}
- Create a new file actions/requestAmendment.ts and add:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function requestAmendment(timesheetId: string, comment: string) {
// Re-verify the caller is a line manager
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
throw new Error('Unauthorized')
}
// Update timesheet status and save the amendment comment
await prisma.timesheet.update({
where: { id: timesheetId },
data: {
status: 'AMENDMENT_REQUESTED',
amendmentComment: comment,
},
})
return { success: true }
}
- Create a new file actions/rejectTimesheet.ts and add:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function rejectTimesheet(timesheetId: string, reason: string) {
// Re-verify the caller is a line manager
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
throw new Error('Unauthorized')
}
// Reject requires a reason — validate before writing
if (!reason.trim()) {
throw new Error('Rejection reason is required')
}
await prisma.timesheet.update({
where: { id: timesheetId },
data: {
status: 'REJECTED',
rejectionReason: reason,
},
})
return { success: true }
}
Why re-verify auth inside each server action?
Server Actions are separate HTTP entry points. A page-level auth check does not protect them. Any client can craft a POST request to the action endpoint directly. Re-verifying inside each action is a defence-in-depth pattern recommended by the Next.js security guide.
Finally, build the approval panel that ties everything together.
- Create a new file TimesheetApprovalPanel.tsx inside components/manager/ and add:
'use client'
import { useState } from 'react'
import { X } from 'lucide-react'
import ConfirmModal from '@/components/ui/ConfirmModal'
import { approveTimesheet } from '@/actions/approveTimesheet'
import { requestAmendment } from '@/actions/requestAmendment'
import { rejectTimesheet } from '@/actions/rejectTimesheet'
interface TimesheetApprovalPanelProps {
timesheetId: string
onClose: () => void
}
export default function TimesheetApprovalPanel({ timesheetId, onClose }: TimesheetApprovalPanelProps) {
// TOIL fields (manager-only inputs)
const [toilHours, setToilHours] = useState(0)
const [toilExpiry, setToilExpiry] = useState('')
// Modal state for Approve and Request Amendment
const [modalAction, setModalAction] = useState<'approve' | 'amend' | null>(null)
// Rejection requires an inline reason textarea
const [showRejectForm, setShowRejectForm] = useState(false)
const [rejectReason, setRejectReason] = useState('')
// Comment for amendment request
const [amendComment, setAmendComment] = useState('')
async function handleConfirm() {
if (modalAction === 'approve') {
await approveTimesheet(timesheetId, toilHours, toilExpiry)
} else if (modalAction === 'amend') {
await requestAmendment(timesheetId, amendComment)
}
setModalAction(null)
onClose()
}
async function handleReject() {
await rejectTimesheet(timesheetId, rejectReason)
setShowRejectForm(false)
onClose()
}
return (
<>
{/* Slide-in panel: drawer on mobile, side panel on desktop */}
<aside
className="fixed inset-y-0 right-0 w-full md:w-[480px] bg-white shadow-xl z-40 overflow-y-auto p-6"
aria-label="Timesheet approval panel"
>
{/* Close button */}
<button onClick={onClose} className="absolute top-4 right-4" aria-label="Close panel">
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-semibold mb-4">Review Timesheet</h2>
<p className="text-sm text-cs-muted mb-6">Timesheet ID: {timesheetId}</p>
{/* TOIL section — manager-only fields */}
<fieldset className="border border-cs-border rounded-lg p-4 mb-6">
<legend className="text-sm font-medium px-2">TOIL Calculation</legend>
<div className="space-y-3">
<div>
<label htmlFor="toil-hours" className="block text-sm mb-1">TOIL Hours</label>
<input
id="toil-hours"
type="number"
min={0}
step={0.5}
value={toilHours}
onChange={(e) => setToilHours(Number(e.target.value))}
className="w-full rounded-md border border-cs-border px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="toil-expiry" className="block text-sm mb-1">TOIL Expiry Date</label>
<input
id="toil-expiry"
type="date"
value={toilExpiry}
onChange={(e) => setToilExpiry(e.target.value)}
className="w-full rounded-md border border-cs-border px-3 py-2 text-sm"
/>
</div>
</div>
</fieldset>
{/* Rejection reason textarea (shown only when Reject is clicked) */}
{showRejectForm && (
<div className="mb-6">
<label htmlFor="reject-reason" className="block text-sm font-medium mb-1">
Reason for rejection (required)
</label>
<textarea
id="reject-reason"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-cs-border px-3 py-2 text-sm"
required
/>
<button
onClick={handleReject}
disabled={!rejectReason.trim()}
className="mt-2 px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
>
Confirm Rejection
</button>
</div>
)}
{/* Manager actions row */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => setModalAction('approve')}
className="px-4 py-2 rounded-md bg-green-600 text-white text-sm"
>
Approve
</button>
<button
onClick={() => setModalAction('amend')}
className="px-4 py-2 rounded-md bg-amber-500 text-white text-sm"
>
Request Amendment
</button>
<button
onClick={() => setShowRejectForm(true)}
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm"
>
Reject
</button>
<button
onClick={onClose}
className="px-4 py-2 rounded-md border border-cs-border text-sm"
>
Add Comment
</button>
</div>
</aside>
{/* Confirmation modal for Approve and Request Amendment */}
<ConfirmModal
isOpen={modalAction !== null}
title={modalAction === 'approve' ? 'Confirm Approval' : 'Confirm Amendment Request'}
message={
modalAction === 'approve'
? 'Are you sure you want to approve this timesheet? This will mark it for payroll processing.'
: 'Are you sure you want to request an amendment? The care worker will be notified.'
}
onConfirm={handleConfirm}
onCancel={() => setModalAction(null)}
/>
</>
)
}
What does this code do?
The panel slides in from the right. On mobile it covers the full width (drawer pattern). On desktop it is fixed at 480px (side panel pattern).
The TOIL section gives managers input fields to record time off in lieu hours and an expiry date. These values are submitted alongside the approve action.
Clicking Approve or Request Amendment opens the ConfirmModal. Clicking Reject reveals an inline textarea that requires a reason before the rejection can be confirmed. This prevents managers from rejecting timesheets without explanation.
- Save all new files: components/ui/ConfirmModal.tsx, actions/approveTimesheet.ts, actions/requestAmendment.ts, actions/rejectTimesheet.ts, and components/manager/TimesheetApprovalPanel.tsx.
- Refresh /manager and click Review on any table row. You should see the approval panel slide in from the right with TOIL inputs and action buttons.
- Click Approve. A confirmation modal should appear with the title "Confirm Approval" and Cancel focused.
Modal not appearing?
Check that ConfirmModal is imported from @/components/ui/ConfirmModal in your TimesheetApprovalPanel. A mismatched import path means the component renders as undefined.
Help me debug why my confirmation modal is not rendering
✔️ Awesome, I've got everything!
Great. Double check that you have saved all files and your panel, table, and modal are all working together.
ⓧ I'd like to double check the full code
Here is the complete app/manager/page.tsx:
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import OverviewBanner from '@/components/manager/OverviewBanner'
import StaffTimesheetTable from '@/components/manager/StaffTimesheetTable'
export default async function ManagerPage() {
// Check session and enforce line manager role
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
redirect('/login')
}
// Fetch all timesheets for staff in the manager's service
const timesheets = await prisma.timesheet.findMany({
where: { user: { service: session.user.service } },
include: { user: true },
orderBy: { weekEnding: 'desc' },
})
// Compute status counts for the overview banner
const counts = {
approved: timesheets.filter((t) => t.status === 'APPROVED').length,
pending: timesheets.filter((t) => t.status === 'PENDING').length,
notSubmitted: timesheets.filter((t) => t.status === 'NOT_SUBMITTED').length,
sickness: timesheets.filter((t) => t.status === 'SICKNESS').length,
}
return (
<main className="p-6 space-y-8">
<OverviewBanner
managerName={session.user.name}
service={session.user.service}
counts={counts}
/>
<StaffTimesheetTable timesheets={timesheets} />
</main>
)
}
Here is the complete components/ui/ConfirmModal.tsx:
'use client'
import { useEffect, useRef } from 'react'
interface ConfirmModalProps {
isOpen: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmModal({ isOpen, title, message, onConfirm, onCancel }: ConfirmModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
// Focus trap: move focus into the modal when it opens
useEffect(() => {
if (isOpen && cancelRef.current) {
cancelRef.current.focus()
}
}, [isOpen])
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen) {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onCancel])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full mx-4"
>
<h2 id="confirm-modal-title" className="text-lg font-semibold mb-2">
{title}
</h2>
<p className="text-sm text-cs-muted mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 rounded-md border border-cs-border text-sm"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 rounded-md bg-cs-primary text-white text-sm"
>
Confirm
</button>
</div>
</div>
</div>
)
}
Here is the complete actions/approveTimesheet.ts:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function approveTimesheet(timesheetId: string, toilHours: number, toilExpiry: string) {
// Re-verify the caller is a line manager
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
throw new Error('Unauthorized')
}
// Update timesheet status and record TOIL hours
await prisma.timesheet.update({
where: { id: timesheetId },
data: {
status: 'APPROVED',
toilHours,
toilExpiry: new Date(toilExpiry),
},
})
return { success: true }
}
Here is the complete actions/requestAmendment.ts:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function requestAmendment(timesheetId: string, comment: string) {
// Re-verify the caller is a line manager
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
throw new Error('Unauthorized')
}
// Update timesheet status and save the amendment comment
await prisma.timesheet.update({
where: { id: timesheetId },
data: {
status: 'AMENDMENT_REQUESTED',
amendmentComment: comment,
},
})
return { success: true }
}
Here is the complete actions/rejectTimesheet.ts:
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function rejectTimesheet(timesheetId: string, reason: string) {
// Re-verify the caller is a line manager
const session = await auth()
if (!session || session.user.role !== 'line_manager') {
throw new Error('Unauthorized')
}
// Reject requires a reason — validate before writing
if (!reason.trim()) {
throw new Error('Rejection reason is required')
}
await prisma.timesheet.update({
where: { id: timesheetId },
data: {
status: 'REJECTED',
rejectionReason: reason,
},
})
return { success: true }
}
Your line manager dashboard is fully operational. Managers can now review, approve, amend, or reject timesheets from a single interface. Next up, you will build the Help and Support hub and run a full accessibility audit to polish the entire portal.
Build Help & Support and Polish the App
In the previous step, you built the line manager dashboard with approval workflows, TOIL calculations, and staff status overview. Now it's time to bring everything together with a Help & Support hub and a final accessibility polish.
Care workers need a place to find answers without calling their manager. You will build a help centre with video guides, downloadable PDFs, and an FAQ accordion that answers the most common payroll questions in plain English. Then you will add an offline indicator and run a full WCAG 2.1 AA accessibility audit across every page.
In this step, get ready to:
- Build the Help & Support page with tabbed sections for video guides, written guides, and FAQ.
- Create the Essential Rules panel, Contact Support cards, and offline indicator.
- Run a full accessibility audit covering keyboard navigation, colour contrast, and touch targets.
Build the Help page with three tab sections
The Help & Support page uses a tabbed interface so care workers can switch between video guides, written guides, and frequently asked questions without loading a new page.
You will start with the FAQ accordion component, then assemble the full page.
- Create a new file at components/help/FAQAccordion.tsx and add the following code:
'use client';
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
// FAQ data covering common care worker questions
const faqItems = [
{
id: 'deadline',
question: 'When do I need to submit my timesheet?',
answer:
'Your timesheet must be submitted by Friday at 5pm for the current week. Late submissions may delay your pay.',
},
{
id: 'sleep-in',
question: 'How is sleep-in pay calculated?',
answer:
'Sleep-in shifts are paid at a flat rate per night. If you are woken and required to work during a sleep-in, those hours are paid at your normal hourly rate.',
},
{
id: 'toil',
question: 'How long do I have to use my TOIL hours?',
answer:
'TOIL (Time Off In Lieu) must be taken within 3 months of it being accrued. After 3 months, unused TOIL will expire. Speak to your line manager to arrange time off.',
},
{
id: 'leave-balance',
question: 'Where can I check my annual leave balance?',
answer:
'Your current leave balance is shown at the top of the Annual Leave page. It updates automatically when leave is approved or taken.',
},
{
id: 'sickness',
question: 'What do I do if I am too sick to work?',
answer:
'Call your line manager before your shift starts. Then complete the sickness report form in this portal. You do not need to give detailed medical information if you prefer not to.',
},
];
export default function FAQAccordion() {
// Track which FAQ item is currently expanded
const [openId, setOpenId] = useState<string | null>(null);
function toggleItem(id: string) {
setOpenId(openId === id ? null : id);
}
return (
<div className="space-y-2">
{faqItems.map((item) => {
const isOpen = openId === item.id;
return (
<div
key={item.id}
className="border border-border rounded-lg overflow-hidden"
>
{/* Accordion trigger button */}
<button
onClick={() => toggleItem(item.id)}
aria-expanded={isOpen}
aria-controls={`faq-panel-${item.id}`}
className="w-full flex items-center justify-between p-4 text-left font-medium text-foreground hover:bg-muted/50 transition-colors min-h-[44px]"
>
<span>{item.question}</span>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Collapsible answer panel */}
<div
id={`faq-panel-${item.id}`}
role="region"
aria-labelledby={`faq-button-${item.id}`}
className={`overflow-hidden transition-all ${
isOpen ? 'max-h-96 p-4 pt-0' : 'max-h-0'
}`}
>
<p className="text-muted-foreground text-sm leading-relaxed pt-2 border-t border-border">
{item.answer}
</p>
</div>
</div>
);
})}
</div>
);
}
What does this code do?
The FAQAccordion component renders a list of expandable question-and-answer items. Only one item can be open at a time, keeping the interface clean.
- aria-expanded tells assistive technology whether the answer panel is visible.
- aria-controls links the button to the panel it reveals, so screen readers can navigate between them.
- The min-h-[44px] class ensures each button meets the minimum 44px touch target for accessibility.
- Save components/help/FAQAccordion.tsx.
Now build the full Help page that uses this accordion alongside video and written guide cards.
- Create a new file at app/help/page.tsx and add the following code:
'use client';
import { useState } from 'react';
import { Play, FileText, HelpCircle } from 'lucide-react';
import FAQAccordion from '@/components/help/FAQAccordion';
import EssentialRulesPanel from '@/components/help/EssentialRulesPanel';
import ContactSupport from '@/components/help/ContactSupport';
// Video guide entries with placeholder thumbnails
const videoGuides = [
{ id: 'v1', title: 'How to Submit Your Timesheet', duration: '3:45' },
{ id: 'v2', title: 'Recording Sleep-in Shifts', duration: '2:30' },
{ id: 'v3', title: 'Booking Annual Leave', duration: '4:10' },
{ id: 'v4', title: 'Reporting Sickness Absence', duration: '2:15' },
];
// Written guide entries with download links
const writtenGuides = [
{ id: 'g1', title: 'Timesheet Step-by-Step Guide', filename: 'timesheet-guide.pdf' },
{ id: 'g2', title: 'Overtime and TOIL Explained', filename: 'overtime-toil-guide.pdf' },
{ id: 'g3', title: 'Annual Leave Policy Summary', filename: 'leave-policy.pdf' },
{ id: 'g4', title: 'Accessibility Features Guide', filename: 'accessibility-guide.pdf' },
];
// Tab configuration
const tabs = [
{ id: 'video', label: 'Video Guides', icon: Play },
{ id: 'written', label: 'Written Guides', icon: FileText },
{ id: 'faq', label: 'FAQ', icon: HelpCircle },
] as const;
type TabId = 'video' | 'written' | 'faq';
export default function HelpPage() {
// Track which content tab is active
const [activeTab, setActiveTab] = useState<TabId>('video');
return (
<main className="p-6 max-w-5xl mx-auto space-y-8">
<h1 className="text-2xl font-bold text-foreground">Help & Support</h1>
{/* Tab navigation bar */}
<div role="tablist" aria-label="Help sections" className="flex gap-2 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 font-medium text-sm transition-colors min-h-[44px] ${
activeTab === tab.id
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* Video Guides tab panel */}
{activeTab === 'video' && (
<div id="panel-video" role="tabpanel" aria-labelledby="tab-video" className="grid grid-cols-1 md:grid-cols-2 gap-4">
{videoGuides.map((video) => (
<div key={video.id} className="border border-border rounded-lg overflow-hidden">
{/* Placeholder thumbnail area */}
<div className="bg-muted h-32 flex items-center justify-center">
<Play className="h-10 w-10 text-muted-foreground" />
</div>
<div className="p-4 flex items-center justify-between">
<span className="font-medium text-foreground text-sm">{video.title}</span>
<span className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded">
{video.duration}
</span>
</div>
</div>
))}
</div>
)}
{/* Written Guides tab panel */}
{activeTab === 'written' && (
<div id="panel-written" role="tabpanel" aria-labelledby="tab-written" className="grid grid-cols-1 md:grid-cols-2 gap-4">
{writtenGuides.map((guide) => (
<div key={guide.id} className="border border-border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-primary" />
<span className="font-medium text-foreground text-sm">{guide.title}</span>
</div>
<a
href={`/guides/${guide.filename}`}
download
className="text-xs text-primary hover:underline min-h-[44px] min-w-[44px] flex items-center justify-center"
>
Download
</a>
</div>
))}
</div>
)}
{/* FAQ tab panel */}
{activeTab === 'faq' && (
<div id="panel-faq" role="tabpanel" aria-labelledby="tab-faq">
<FAQAccordion />
</div>
)}
{/* Essential rules and contact support below the tabs */}
<EssentialRulesPanel />
<ContactSupport />
</main>
);
}
What does this code do?
The Help page uses ARIA tab roles to make the tabbed interface accessible to screen readers.
- role="tablist" groups the tab buttons so screen readers announce them as a connected set.
- role="tab" and role="tabpanel" link buttons to their content panels using matching aria-controls IDs.
- The video guides render placeholder thumbnail areas so you can swap in real video previews later.
- Written guides use the HTML download attribute so clicking triggers a file save dialog.
- Save app/help/page.tsx.
Seeing import errors?
The page imports EssentialRulesPanel and ContactSupport which you have not created yet. Your editor will show red squiggles until you build those components in the next substep.
help me fix import errors in my Help page
Create the Essential Rules panel, Contact Support, and offline indicator
Below the tabbed content, the Help page needs two supporting sections. You will also build a global offline indicator that alerts care workers when their connection drops.
- Create a new file at components/help/EssentialRulesPanel.tsx and add the following code:
import { AlertCircle } from 'lucide-react';
// Key payroll rules that every care worker should know
const rules = [
'Submit your timesheet by Friday at 5pm each week.',
'Sleep-in shifts are paid at the agreed flat rate per night.',
'TOIL must be used within 3 months or it will expire.',
'Annual leave requests need at least 2 weeks notice.',
'Report sickness before your shift starts by calling your manager.',
'Overtime must be pre-authorised by your line manager.',
];
export default function EssentialRulesPanel() {
return (
<aside className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<div className="flex items-center gap-2 mb-4">
<AlertCircle className="h-5 w-5 text-amber-600" />
<h2 className="text-lg font-semibold text-foreground">Essential Rules</h2>
</div>
<ul className="space-y-2">
{rules.map((rule, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-foreground">
<span className="text-amber-600 mt-1">•</span>
<span>{rule}</span>
</li>
))}
</ul>
</aside>
);
}
What does this code do?
The EssentialRulesPanel uses the <aside> element to indicate supplementary content. Screen readers can identify it as separate from the main page flow.
The amber colour scheme draws attention without competing with the primary action areas of the page.
- Save components/help/EssentialRulesPanel.tsx.
- Create a new file at components/help/ContactSupport.tsx and add the following code:
import { Phone, Mail, MessageCircle } from 'lucide-react';
export default function ContactSupport() {
return (
<section className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">Contact Support</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Call support card */}
<div className="border border-border rounded-lg p-4 text-center space-y-2">
<Phone className="h-6 w-6 text-primary mx-auto" />
<h3 className="font-medium text-foreground">Call Us</h3>
<p className="text-sm text-muted-foreground">Mon-Fri, 9am-5pm</p>
<a
href="tel:08001234567"
className="text-primary text-sm font-medium hover:underline min-h-[44px] inline-flex items-center"
>
0800 123 4567
</a>
</div>
{/* Email support card */}
<div className="border border-border rounded-lg p-4 text-center space-y-2">
<Mail className="h-6 w-6 text-primary mx-auto" />
<h3 className="font-medium text-foreground">Email</h3>
<p className="text-sm text-muted-foreground">We reply within 24 hours</p>
<a
href="mailto:support@creativesupport.org.uk"
className="text-primary text-sm font-medium hover:underline min-h-[44px] inline-flex items-center"
>
support@creativesupport.org.uk
</a>
</div>
{/* Live chat card */}
<div className="border border-border rounded-lg p-4 text-center space-y-2">
<MessageCircle className="h-6 w-6 text-primary mx-auto" />
<h3 className="font-medium text-foreground">Live Chat</h3>
<p className="text-sm text-muted-foreground">Available during office hours</p>
<button className="text-primary text-sm font-medium hover:underline min-h-[44px] min-w-[44px]">
Start chat
</button>
</div>
</div>
</section>
);
}
What does this code do?
ContactSupport renders three cards with different contact methods. The phone link uses a tel: href so care workers on mobile can tap to call directly.
The email link uses a mailto: href to open the device's default email client. The Live Chat button is a placeholder ready for a real chat integration.
- Save components/help/ContactSupport.tsx.
Now build the offline indicator that warns care workers when their internet drops.
- Create a new file at components/ui/OfflineIndicator.tsx and add the following code:
'use client';
import { useState, useEffect } from 'react';
import { WifiOff } from 'lucide-react';
export default function OfflineIndicator() {
// Initialise from the browser's online status
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// Set initial state from navigator
setIsOnline(navigator.onLine);
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
// Listen for connectivity changes
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Only render the banner when offline
if (isOnline) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed top-0 left-0 right-0 z-50 bg-red-600 text-white text-center py-2 px-4 flex items-center justify-center gap-2 text-sm"
>
<WifiOff className="h-4 w-4" />
<span>You are offline. Changes will save when reconnected.</span>
</div>
);
}
What does this code do?
The OfflineIndicator listens for browser connectivity events. When the device goes offline, it renders a fixed banner at the top of every page.
- aria-live="polite" ensures screen readers announce the banner without interrupting the user mid-task.
- role="status" marks the element as a live region that reports non-urgent updates.
- The state initialises to true (online) to avoid a flash of the offline banner during server-side rendering.
- Save components/ui/OfflineIndicator.tsx.
Now add the OfflineIndicator to your root layout so it appears on every page.
- In app/layout.tsx, add this import at the top of the file alongside your existing imports:
import OfflineIndicator from '@/components/ui/OfflineIndicator';
- Inside the <body> element, add the <OfflineIndicator /> component before your existing AccessibilityToolbar and LayoutShell:
<body>
<OfflineIndicator />
<AccessibilityToolbar />
<LayoutShell>
{children}
</LayoutShell>
</body>
Why put it first in the body?
Placing the offline banner before all other content ensures it appears above the accessibility toolbar and navigation. Its fixed positioning and z-50 class keep it visible regardless of scroll position.
- Save app/layout.tsx.
- Run the dev server and navigate to /help in your browser.
- You should see the tab bar with Video Guides, Written Guides, and FAQ tabs, the Essential Rules panel in amber, and three Contact Support cards below.
Test the offline indicator
Open DevTools, go to the Network tab, and toggle the Offline checkbox. A red banner should appear at the top of the page. Uncheck it and the banner disappears.
Banner not appearing when offline?
Make sure the OfflineIndicator import path matches exactly: @/components/ui/OfflineIndicator.
Check that the file starts with 'use client'. The navigator.onLine API is browser-only and requires a Client Component.
help me debug my OfflineIndicator component
Run the full accessibility audit
Your portal serves 5200+ care workers of all ages and digital confidence levels. Before deploying, you need to verify that every page meets WCAG 2.1 AA accessibility standards.
You will add global focus-visible styles, then run a manual audit covering keyboard navigation, colour contrast, and touch targets.
- Open app/globals.css and add these styles at the bottom of the file, after your existing design tokens:
/* Global focus ring for keyboard navigation */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Utility: minimum touch target size for accessibility */
.touch-target {
min-height: 44px;
min-width: 44px;
}
What does this code do?
The focus-visible selector only shows the outline when the user navigates with a keyboard, not when clicking with a mouse. This prevents visual clutter for mouse users while keeping the indicator visible for keyboard users.
The .touch-target utility class enforces a minimum 44x44px interactive area, matching the WCAG 2.1 AA target size requirement.
- Save app/globals.css.
Now run the manual accessibility audit across your entire app.
- Open your app in the browser and navigate to every page (Dashboard, Timesheet, Overtime, Annual Leave, Sickness, Manager, Help).
- On each page, press Tab repeatedly and verify you can reach every interactive element (buttons, links, inputs) with a visible blue focus ring.
- Verify that no element is skipped or trapped. Press Shift+Tab to move backwards and confirm the order is logical.
- Check colour contrast: open DevTools, inspect any text element, and verify the computed contrast ratio is at least 4.5:1 for normal text and 3:1 for large text (18px or larger).
- Verify all interactive elements (buttons, links, tab triggers) have a minimum clickable area of 44x44px by inspecting their computed dimensions in DevTools.
- Confirm all <img> elements have alt text and all form inputs have associated <label> elements.
What is a WCAG 2.1 AA audit?
WCAG 2.1 AA is the internationally recognised standard for web accessibility. It requires a minimum 4.5:1 contrast ratio for normal text, 3:1 for large text, and 44px minimum touch targets for interactive elements.
Meeting this standard ensures your portal is usable by people with low vision, motor impairments, and cognitive differences.
- On macOS, open VoiceOver with Cmd+F5 and navigate through the Help page to verify screen reader announcements.
- Confirm the tab bar announces each tab's selected state and the FAQ accordion announces expanded or collapsed status.
Focus ring not visible?
Make sure you saved app/globals.css and that your browser has refreshed. If you are clicking elements with a mouse, the ring will not appear. Use the Tab key to trigger focus-visible.
help me debug my focus-visible styles not showing
- Once the audit passes, deploy your app to Vercel by pushing your code to GitHub and confirming the production deployment succeeds with all environment variables set.
Use Lighthouse for a quick automated check
In Chrome DevTools, open the Lighthouse tab and run an Accessibility audit. Aim for a score of 90 or above. This catches issues like missing ARIA labels and low contrast that manual testing might miss.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and that your deployment is live.
ⓧ I'd like to double check the full code
Here is the full code for each new file created in this step.
'use client';
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
const faqItems = [
{
id: 'deadline',
question: 'When do I need to submit my timesheet?',
answer:
'Your timesheet must be submitted by Friday at 5pm for the current week. Late submissions may delay your pay.',
},
{
id: 'sleep-in',
question: 'How is sleep-in pay calculated?',
answer:
'Sleep-in shifts are paid at a flat rate per night. If you are woken and required to work during a sleep-in, those hours are paid at your normal hourly rate.',
},
{
id: 'toil',
question: 'How long do I have to use my TOIL hours?',
answer:
'TOIL (Time Off In Lieu) must be taken within 3 months of it being accrued. After 3 months, unused TOIL will expire. Speak to your line manager to arrange time off.',
},
{
id: 'leave-balance',
question: 'Where can I check my annual leave balance?',
answer:
'Your current leave balance is shown at the top of the Annual Leave page. It updates automatically when leave is approved or taken.',
},
{
id: 'sickness',
question: 'What do I do if I am too sick to work?',
answer:
'Call your line manager before your shift starts. Then complete the sickness report form in this portal. You do not need to give detailed medical information if you prefer not to.',
},
];
export default function FAQAccordion() {
const [openId, setOpenId] = useState<string | null>(null);
function toggleItem(id: string) {
setOpenId(openId === id ? null : id);
}
return (
<div className="space-y-2">
{faqItems.map((item) => {
const isOpen = openId === item.id;
return (
<div
key={item.id}
className="border border-border rounded-lg overflow-hidden"
>
<button
onClick={() => toggleItem(item.id)}
aria-expanded={isOpen}
aria-controls={`faq-panel-${item.id}`}
className="w-full flex items-center justify-between p-4 text-left font-medium text-foreground hover:bg-muted/50 transition-colors min-h-[44px]"
>
<span>{item.question}</span>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
<div
id={`faq-panel-${item.id}`}
role="region"
aria-labelledby={`faq-button-${item.id}`}
className={`overflow-hidden transition-all ${
isOpen ? 'max-h-96 p-4 pt-0' : 'max-h-0'
}`}
>
<p className="text-muted-foreground text-sm leading-relaxed pt-2 border-t border-border">
{item.answer}
</p>
</div>
</div>
);
})}
</div>
);
}
'use client';
import { useState } from 'react';
import { Play, FileText, HelpCircle } from 'lucide-react';
import FAQAccordion from '@/components/help/FAQAccordion';
import EssentialRulesPanel from '@/components/help/EssentialRulesPanel';
import ContactSupport from '@/components/help/ContactSupport';
const videoGuides = [
{ id: 'v1', title: 'How to Submit Your Timesheet', duration: '3:45' },
{ id: 'v2', title: 'Recording Sleep-in Shifts', duration: '2:30' },
{ id: 'v3', title: 'Booking Annual Leave', duration: '4:10' },
{ id: 'v4', title: 'Reporting Sickness Absence', duration: '2:15' },
];
const writtenGuides = [
{ id: 'g1', title: 'Timesheet Step-by-Step Guide', filename: 'timesheet-guide.pdf' },
{ id: 'g2', title: 'Overtime and TOIL Explained', filename: 'overtime-toil-guide.pdf' },
{ id: 'g3', title: 'Annual Leave Policy Summary', filename: 'leave-policy.pdf' },
{ id: 'g4', title: 'Accessibility Features Guide', filename: 'accessibility-guide.pdf' },
];
const tabs = [
{ id: 'video', label: 'Video Guides', icon: Play },
{ id: 'written', label: 'Written Guides', icon: FileText },
{ id: 'faq', label: 'FAQ', icon: HelpCircle },
] as const;
type TabId = 'video' | 'written' | 'faq';
export default function HelpPage() {
const [activeTab, setActiveTab] = useState<TabId>('video');
return (
<main className="p-6 max-w-5xl mx-auto space-y-8">
<h1 className="text-2xl font-bold text-foreground">Help & Support</h1>
<div role="tablist" aria-label="Help sections" className="flex gap-2 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 font-medium text-sm transition-colors min-h-[44px] ${
activeTab === tab.id
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{activeTab === 'video' && (
<div id="panel-video" role="tabpanel" aria-labelledby="tab-video" className="grid grid-cols-1 md:grid-cols-2 gap-4">
{videoGuides.map((video) => (
<div key={video.id} className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted h-32 flex items-center justify-center">
<Play className="h-10 w-10 text-muted-foreground" />
</div>
<div className="p-4 flex items-center justify-between">
<span className="font-medium text-foreground text-sm">{video.title}</span>
<span className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded">
{video.duration}
</span>
</div>
</div>
))}
</div>
)}
{activeTab === 'written' && (
<div id="panel-written" role="tabpanel" aria-labelledby="tab-written" className="grid grid-cols-1 md:grid-cols-2 gap-4">
{writtenGuides.map((guide) => (
<div key={guide.id} className="border border-border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-primary" />
<span className="font-medium text-foreground text-sm">{guide.title}</span>
</div>
<a
href={`/guides/${guide.filename}`}
download
className="text-xs text-primary hover:underline min-h-[44px] min-w-[44px] flex items-center justify-center"
>
Download
</a>
</div>
))}
</div>
)}
{activeTab === 'faq' && (
<div id="panel-faq" role="tabpanel" aria-labelledby="tab-faq">
<FAQAccordion />
</div>
)}
<EssentialRulesPanel />
<ContactSupport />
</main>
);
}
import { AlertCircle } from 'lucide-react';
const rules = [
'Submit your timesheet by Friday at 5pm each week.',
'Sleep-in shifts are paid at the agreed flat rate per night.',
'TOIL must be used within 3 months or it will expire.',
'Annual leave requests need at least 2 weeks notice.',
'Report sickness before your shift starts by calling your manager.',
'Overtime must be pre-authorised by your line manager.',
];
export default function EssentialRulesPanel() {
return (
<aside className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<div className="flex items-center gap-2 mb-4">
<AlertCircle className="h-5 w-5 text-amber-600" />
<h2 className="text-lg font-semibold text-foreground">Essential Rules</h2>
</div>
<ul className="space-y-2">
{rules.map((rule, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-foreground">
<span className="text-amber-600 mt-1">•</span>
<span>{rule}</span>
</li>
))}
</ul>
</aside>
);
}
import { Phone, Mail, MessageCircle } from 'lucide-react';
export default function ContactSupport() {
return (
<section className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">Contact Support</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border border-border rounded-lg p-4 text-center space-y-2">
<Phone className="h-6 w-6 text-primary mx-auto" />
<h3 className="font-medium text-foreground">Call Us</h3>
<p className="text-sm text-muted-foreground">Mon-Fri, 9am-5pm</p>
<a
href="tel:08001234567"
className="text-primary text-sm font-medium hover:underline min-h-[44px] inline-flex items-center"
>
0800 123 4567
</a>
</div>
<div className="border border-border rounded-lg p-4 text-center space-y-2">
<Mail className="h-6 w-6 text-primary mx-auto" />
<h3 className="font-medium text-foreground">Email</h3>
<p className="text-sm text-muted-foreground">We reply within 24 hours</p>
<a
href="mailto:support@creativesupport.org.uk"
className="text-primary text-sm font-medium hover:underline min-h-[44px] inline-flex items-center"
>
support@creativesupport.org.uk
</a>
</div>
<div className="border border-border rounded-lg p-4 text-center space-y-2">
<MessageCircle className="h-6 w-6 text-primary mx-auto" />
<h3 className="font-medium text-foreground">Live Chat</h3>
<p className="text-sm text-muted-foreground">Available during office hours</p>
<button className="text-primary text-sm font-medium hover:underline min-h-[44px] min-w-[44px]">
Start chat
</button>
</div>
</div>
</section>
);
}
'use client';
import { useState, useEffect } from 'react';
import { WifiOff } from 'lucide-react';
export default function OfflineIndicator() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
setIsOnline(navigator.onLine);
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (isOnline) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed top-0 left-0 right-0 z-50 bg-red-600 text-white text-center py-2 px-4 flex items-center justify-center gap-2 text-sm"
>
<WifiOff className="h-4 w-4" />
<span>You are offline. Changes will save when reconnected.</span>
</div>
);
}
/* Global focus ring for keyboard navigation */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Utility: minimum touch target size for accessibility */
.touch-target {
min-height: 44px;
min-width: 44px;
}
Secret mission
Real-Time Notifications
Right now, care workers submit timesheets into the void. Line managers have no idea when new timesheets arrive until they manually check. Build a real-time notification system that closes this feedback loop, alerting managers instantly when timesheets land, and notifying care workers when their submissions are approved or need amendments.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Vercel's free Hobby tier for both hosting and Postgres, so there are no ongoing costs while the deployment remains live.
Resources you used:
- Vercel project deployment (free tier: 100GB bandwidth/month).
- Vercel Postgres database (free tier: 256MB storage on Hobby plan).
- GitHub repository for the project source code.
- Local project files in the creative-support-portal folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want the portal accessible at its Vercel URL.
- Your Vercel deployment stays live on the free Hobby plan at no cost.
- Your Postgres database remains available within the 256MB free-tier allowance.
- Your local project files stay on your Desktop ready for further development.
Pause - I'll come back to this later
Shut down running processes to free up local resources while keeping your cloud deployment intact.
- Stop your local development server if it is still running by pressing Ctrl+C in the terminal.
- Your Vercel deployment and Postgres database remain available on the free tier. No charges accrue while idle.
- When you are ready to resume, open the creative-support-portal folder and run npm run dev to start developing again.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete the Vercel project and database:
- Open your Vercel dashboard and select the creative-support-portal project.
- Click Settings in the sidebar.
- Scroll to the bottom of the General page. In the Delete Project section, click Delete.
- Enter the project name to confirm, then click Continue. This removes the deployment, domains, and environment variables.
Delete the Postgres database from Stores:
- In the Vercel dashboard, navigate to Storage (or visit your team's Stores page).
- Select your Postgres store, open its Settings, and delete it from there.
Delete the GitHub repository:
- Open your repository on GitHub.
- Click Settings (under the repository name).
- Scroll down to the Danger Zone section and click Delete this repository.
- Follow the confirmation prompts and type the repository name to confirm.
Delete local project files:
- Remove the project folder by running the appropriate command for your system:
macOS
rm -rf ~/Desktop/creative-support-portal
Windows
rmdir /s /q %USERPROFILE%\Desktop\creative-support-portal
That's a Wrap!
That's a Wrap!
Brilliant work. You have built a complete, accessible, production-grade timesheet portal that genuinely cares for its users.
You've learned how to:
- Built a full 8-page web application with Next.js 16, Tailwind CSS v4, and Auth.js v5, featuring role-based authentication with middleware-protected routes for care workers and line managers.
- Implemented WCAG 2.1 AA accessibility throughout the portal, including a persistent toolbar with font scaling and high contrast mode, full keyboard navigation, screen reader support, and responsive layouts serving both mobile-first care workers and desktop managers.
- Designed a 10-step timesheet wizard with auto-save, smart warnings, signature capture, and care-specific fields like sleep-ins, night supplements, and on-call recording. Deployed the entire portal to Vercel, ready to serve 5200+ staff.
- Secret Mission: Added real-time push notifications connecting care workers and managers in a responsive feedback loop, with a notification bell, unread count badge, and user preferences.
Ready to quiz yourself?