feat(dashboard): Enhance dashboard with PageHeader, StatCard, and auth fixes

- Create PageHeader component with title, description, and action slots
- Create StatCard component with trend indicators and icons
- Update dashboard with KPI cards, Quick Actions, and Allocation Preview
- Polish login page with branding and centered layout
- Fix auth redirect: authenticated users accessing /login go to dashboard
- Fix page refresh: auth state persists, no blank page
- Fix sidebar: visible after login, toggle works, state persists
- Fix CSS import: add app.css to layout, fix DaisyUI import path
- Fix breadcrumbs: home icon links to /dashboard
- Add comprehensive E2E and unit tests

Refs: openspec/changes/p03-dashboard-enhancement
Closes: p03-dashboard-enhancement
This commit is contained in:
2026-02-18 18:14:57 -05:00
parent 493cb78173
commit 96f1d0a6e5
22 changed files with 770 additions and 138 deletions

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import { page } from '$app/stores';
import AppLayout from '$lib/components/layout/AppLayout.svelte';
import { initAuth } from '$lib/stores/auth';
import '$lib/stores/layout';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import AppLayout from '$lib/components/layout/AppLayout.svelte';
import { browser } from '$app/environment';
import { initAuth } from '$lib/stores/auth';
import '$lib/stores/layout';
import '../app.css';
const publicPages = ['/login', '/auth'];
$: shouldUseAppLayout = !publicPages.some((path) => $page.url.pathname.startsWith(path));
const publicPages = ['/login', '/auth'];
$: shouldUseAppLayout = !publicPages.some((path) => $page.url.pathname.startsWith(path));
onMount(() => {
if (browser) {
initAuth();
});
}
</script>
{#if shouldUseAppLayout}

View File

@@ -1,51 +1,112 @@
<script lang="ts">
import { user, logout } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import StatCard from '$lib/components/common/StatCard.svelte';
import { selectedPeriod } from '$lib/stores/period';
import {
Folder,
Users,
Calendar,
BarChart3,
Plus,
ArrowRight
} from 'lucide-svelte';
async function handleLogout() {
await logout();
goto('/login');
}
// TODO: Fetch from API in future
const stats = {
activeProjects: 14,
teamMembers: 8,
allocationsThisMonth: 186,
avgUtilization: 87
};
</script>
<svelte:head>
<title>Dashboard - Headroom</title>
<title>Dashboard | Headroom</title>
</svelte:head>
<div class="max-w-4xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title text-3xl mb-4">Welcome to Headroom! 👋</h1>
{#if $user}
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>Logged in as {$user.email} ({$user.role})</span>
</div>
{/if}
<PageHeader
title="Dashboard"
description="Overview of your resource allocation"
>
{#snippet children()}
<button class="btn btn-primary btn-sm gap-2">
<Plus size={16} />
New Allocation
</button>
{/snippet}
</PageHeader>
<p class="text-lg mb-4">
You have successfully authenticated. This is a protected dashboard page
that requires a valid JWT token to access.
</p>
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard
title="Active Projects"
value={stats.activeProjects}
trend="up"
trendValue="+2"
description="from last month"
icon={Folder}
/>
<StatCard
title="Team Members"
value={stats.teamMembers}
trend="neutral"
description="active"
icon={Users}
/>
<StatCard
title="Allocations (hrs)"
value={stats.allocationsThisMonth}
trend="down"
trendValue="-12"
description="vs capacity"
icon={Calendar}
/>
<StatCard
title="Avg Utilization"
value={`${stats.avgUtilization}%`}
trend="up"
trendValue="+5%"
description="from last month"
icon={BarChart3}
/>
</div>
<div class="divider"></div>
<h2 class="text-xl font-bold mb-2">Authentication Features Implemented:</h2>
<ul class="list-disc list-inside space-y-2 mb-6">
<li>✅ JWT Token Authentication</li>
<li>✅ Token Auto-refresh on 401</li>
<li>✅ Protected Route Guards</li>
<li>✅ Form Validation with Zod</li>
<li>✅ Role-based Access Control</li>
<li>✅ Redis Token Storage</li>
</ul>
<div class="card-actions">
<button class="btn btn-error" on:click={handleLogout}>
Logout
</button>
</div>
<!-- Quick Links -->
<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">
<div class="card-body">
<h2 class="card-title text-lg">Quick Actions</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-2">
<a href="/team-members" class="btn btn-ghost justify-start gap-2">
<Users size={18} />
Team
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
<a href="/projects" class="btn btn-ghost justify-start gap-2">
<Folder size={18} />
Projects
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
<a href="/allocations" class="btn btn-ghost justify-start gap-2">
<Calendar size={18} />
Allocate
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
<a href="/reports/forecast" class="btn btn-ghost justify-start gap-2">
<BarChart3 size={18} />
Forecast
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
</div>
</div>
</div>
<!-- Placeholder for Allocation Preview -->
<div class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">Allocation Preview</h2>
<p class="text-base-content/50 text-sm">
Allocation matrix for {$selectedPeriod} will appear here.
</p>
<div class="skeleton h-48 w-full mt-4"></div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import LoginForm from '$lib/components/Auth/LoginForm.svelte';
import { login, auth } from '$lib/stores/auth';
import { LayoutDashboard } from 'lucide-svelte';
async function handleLogin(event: CustomEvent<{ email: string; password: string }>) {
const { email, password } = event.detail;
@@ -17,14 +18,23 @@
<title>Login - Headroom</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title text-2xl text-center justify-center mb-6">
Welcome to Headroom
</h1>
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4">
<!-- App Branding/Logo -->
<div class="mb-8 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl shadow-lg mb-4">
<LayoutDashboard size={32} class="text-primary-content" />
</div>
<h1 class="text-3xl font-bold text-base-content">Headroom</h1>
<p class="text-base-content/60 mt-2">Resource Planning & Capacity Management</p>
</div>
<div class="card w-full max-w-md bg-base-100 shadow-xl border border-base-300">
<div class="card-body p-8">
<h2 class="card-title text-xl text-center justify-center mb-2">
Welcome Back
</h2>
<p class="text-center text-base-content/70 mb-6">
<p class="text-center text-base-content/60 mb-6">
Sign in to access your dashboard
</p>
@@ -34,12 +44,21 @@
errorMessage={$auth.error}
/>
<div class="divider"></div>
<div class="divider text-base-content/40 text-sm">Demo Access</div>
<div class="text-center text-sm text-base-content/60">
<p>Demo credentials:</p>
<p class="font-mono mt-1">admin@example.com / password</p>
<div class="text-center text-sm">
<p class="text-base-content/60">Use these credentials to explore:</p>
<div class="mt-2 p-3 bg-base-200 rounded-lg">
<code class="text-sm font-mono text-base-content">admin@example.com</code>
<span class="text-base-content/40 mx-2">/</span>
<code class="text-sm font-mono text-base-content">password</code>
</div>
</div>
</div>
</div>
<!-- Footer -->
<p class="mt-8 text-sm text-base-content/40 text-center">
Headroom - Engineering Resource Planning
</p>
</div>

View File

@@ -1 +1,16 @@
import { browser } from '$app/environment';
import { redirect } from '@sveltejs/kit';
import { getAccessToken, isJwtExpired, isValidJwtFormat } from '$lib/services/api';
export const ssr = false;
export const load = () => {
if (!browser) return;
const token = getAccessToken();
const isAuthenticated = Boolean(token && isValidJwtFormat(token) && !isJwtExpired(token));
if (isAuthenticated) {
throw redirect(307, '/dashboard');
}
};