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:
@@ -1,5 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "daisyui";
|
||||
@import "daisyui/daisyui.css";
|
||||
|
||||
:root {
|
||||
--sidebar-width-expanded: 240px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-sidebar="expanded">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
|
||||
110
frontend/src/lib/components/common/StatCard.spec.ts
Normal file
110
frontend/src/lib/components/common/StatCard.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import StatCard from './StatCard.svelte';
|
||||
import { Folder } from 'lucide-svelte';
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('renders value', () => {
|
||||
render(StatCard, {
|
||||
props: {
|
||||
title: 'Active Projects',
|
||||
value: 14
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('14')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active Projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string value', () => {
|
||||
render(StatCard, {
|
||||
props: {
|
||||
title: 'Utilization',
|
||||
value: '87%'
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('87%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(StatCard, {
|
||||
props: {
|
||||
title: 'Team Members',
|
||||
value: 8,
|
||||
description: 'active'
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct trend colors for up trend', () => {
|
||||
const { container } = render(StatCard, {
|
||||
props: {
|
||||
title: 'Projects',
|
||||
value: 14,
|
||||
trend: 'up',
|
||||
trendValue: '+2'
|
||||
}
|
||||
});
|
||||
|
||||
const trendElement = container.querySelector('.text-success');
|
||||
expect(trendElement).toBeInTheDocument();
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct trend colors for down trend', () => {
|
||||
const { container } = render(StatCard, {
|
||||
props: {
|
||||
title: 'Allocations',
|
||||
value: 186,
|
||||
trend: 'down',
|
||||
trendValue: '-12'
|
||||
}
|
||||
});
|
||||
|
||||
const trendElement = container.querySelector('.text-error');
|
||||
expect(trendElement).toBeInTheDocument();
|
||||
expect(screen.getByText('-12')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies neutral trend color when trend is neutral', () => {
|
||||
const { container } = render(StatCard, {
|
||||
props: {
|
||||
title: 'Team',
|
||||
value: 8,
|
||||
trend: 'neutral',
|
||||
trendValue: '0'
|
||||
}
|
||||
});
|
||||
|
||||
const trendElement = container.querySelector('.text-base-content\\/50');
|
||||
expect(trendElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const { container } = render(StatCard, {
|
||||
props: {
|
||||
title: 'Projects',
|
||||
value: 14,
|
||||
icon: Folder
|
||||
}
|
||||
});
|
||||
|
||||
// Icon should render an SVG
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses DaisyUI card classes', () => {
|
||||
const { container } = render(StatCard, {
|
||||
props: {
|
||||
title: 'Test',
|
||||
value: 100
|
||||
}
|
||||
});
|
||||
|
||||
const card = container.querySelector('.card');
|
||||
expect(card).toBeInTheDocument();
|
||||
expect(card).toHaveClass('bg-base-100');
|
||||
expect(card).toHaveClass('shadow-sm');
|
||||
});
|
||||
});
|
||||
59
frontend/src/lib/components/common/StatCard.svelte
Normal file
59
frontend/src/lib/components/common/StatCard.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-svelte';
|
||||
import type { ComponentType, SvelteComponent } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
trendValue?: string;
|
||||
icon?: ComponentType<SvelteComponent>;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend = 'neutral',
|
||||
trendValue,
|
||||
icon: Icon
|
||||
}: Props = $props();
|
||||
|
||||
const trendColorMap: Record<'up' | 'down' | 'neutral', string> = {
|
||||
up: 'text-success',
|
||||
down: 'text-error',
|
||||
neutral: 'text-base-content/50'
|
||||
};
|
||||
|
||||
const trendIconMap: Record<'up' | 'down' | 'neutral', ComponentType<SvelteComponent>> = {
|
||||
up: TrendingUp,
|
||||
down: TrendingDown,
|
||||
neutral: Minus
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="stat-card card bg-base-100 shadow-sm border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="stat-title text-sm text-base-content/70">{title}</div>
|
||||
{#if Icon}
|
||||
<div class="text-base-content/50">
|
||||
<svelte:component this={Icon} size={20} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="stat-value text-3xl font-bold mt-1">{value}</div>
|
||||
<div class="stat-desc flex items-center gap-1 mt-1">
|
||||
{#if trendValue}
|
||||
<span class={trendColorMap[trend]}>
|
||||
<svelte:component this={trendIconMap[trend]} size={14} />
|
||||
</span>
|
||||
<span class={trendColorMap[trend]}>{trendValue}</span>
|
||||
{/if}
|
||||
{#if description}
|
||||
<span class="text-base-content/50">{description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
export function generateBreadcrumbs(pathname: string): Breadcrumb[] {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const crumbs: Breadcrumb[] = [{ label: 'Home', href: '/' }];
|
||||
const crumbs: Breadcrumb[] = [{ label: 'Home', href: '/dashboard' }];
|
||||
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<nav class="breadcrumbs text-sm" aria-label="Breadcrumb" data-testid="breadcrumbs">
|
||||
<ul>
|
||||
{#each crumbs as crumb, i (crumb.href)}
|
||||
{#each crumbs as crumb, i (crumb.href + '-' + i)}
|
||||
<li>
|
||||
{#if i === 0}
|
||||
<a href={crumb.href} aria-label="Home">
|
||||
|
||||
36
frontend/src/lib/components/layout/PageHeader.spec.ts
Normal file
36
frontend/src/lib/components/layout/PageHeader.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import PageHeader from './PageHeader.svelte';
|
||||
|
||||
describe('PageHeader', () => {
|
||||
it('renders title', () => {
|
||||
render(PageHeader, { props: { title: 'Test Page' } });
|
||||
expect(screen.getByText('Test Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(PageHeader, {
|
||||
props: {
|
||||
title: 'Test Page',
|
||||
description: 'This is a test description'
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('This is a test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render description when not provided', () => {
|
||||
const { container } = render(PageHeader, { props: { title: 'Test Page' } });
|
||||
expect(container.querySelector('p')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action buttons via children snippet', () => {
|
||||
const { container } = render(PageHeader, {
|
||||
props: {
|
||||
title: 'Test Page'
|
||||
}
|
||||
});
|
||||
|
||||
// The component should have a slot for children
|
||||
expect(container.querySelector('.page-header')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
frontend/src/lib/components/layout/PageHeader.svelte
Normal file
27
frontend/src/lib/components/layout/PageHeader.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, description, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="page-header mb-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-base-content">{title}</h1>
|
||||
{#if description}
|
||||
<p class="text-base-content/70 mt-1">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if children}
|
||||
<div class="flex items-center gap-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
@@ -26,6 +27,7 @@
|
||||
toggleTheme
|
||||
} from '$lib/stores/layout';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import type { SidebarState } from '$lib/types/layout';
|
||||
import SidebarSection from './SidebarSection.svelte';
|
||||
|
||||
$: isExpanded = $sidebarState === 'expanded';
|
||||
@@ -46,13 +48,22 @@
|
||||
document.documentElement.setAttribute('data-sidebar', $sidebarState);
|
||||
}
|
||||
|
||||
function getSidebarStateForWidth(width: number): SidebarState {
|
||||
if (width < 768) return 'hidden';
|
||||
if (width < 1280) return 'collapsed';
|
||||
return 'expanded';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($sidebarState === 'expanded') {
|
||||
if (window.innerWidth < 768) {
|
||||
setSidebarState('hidden');
|
||||
} else if (window.innerWidth < 1280) {
|
||||
setSidebarState('collapsed');
|
||||
}
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const currentState = get(sidebarState);
|
||||
const desiredState = getSidebarStateForWidth(window.innerWidth);
|
||||
|
||||
if (window.innerWidth < 768 && currentState !== 'hidden') {
|
||||
setSidebarState('hidden');
|
||||
} else if (currentState === 'hidden' && window.innerWidth >= 768) {
|
||||
setSidebarState(desiredState);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', toggleWithShortcut);
|
||||
|
||||
@@ -15,24 +15,46 @@ function isTheme(value: string | null): value is Theme {
|
||||
return value === 'light' || value === 'dark';
|
||||
}
|
||||
|
||||
function getDefaultSidebarForViewport(): SidebarState {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_SIDEBAR_STATE;
|
||||
}
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
if (window.innerWidth < 1280) {
|
||||
return 'collapsed';
|
||||
}
|
||||
|
||||
return 'expanded';
|
||||
}
|
||||
|
||||
function getInitialSidebarState(): SidebarState {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_SIDEBAR_STATE;
|
||||
if (typeof localStorage === 'undefined') return getDefaultSidebarForViewport();
|
||||
|
||||
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
return isSidebarState(stored) ? stored : DEFAULT_SIDEBAR_STATE;
|
||||
if (isSidebarState(stored) && stored !== 'hidden') {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return getDefaultSidebarForViewport();
|
||||
}
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_THEME;
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_THEME;
|
||||
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (isTheme(stored)) return stored;
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (isTheme(stored)) return stored;
|
||||
|
||||
const prefersDark =
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return prefersDark ? 'dark' : DEFAULT_THEME;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
return prefersDark ? 'dark' : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function applyTheme(value: Theme): void {
|
||||
@@ -40,12 +62,21 @@ function applyTheme(value: Theme): void {
|
||||
document.documentElement.setAttribute('data-theme', value);
|
||||
}
|
||||
|
||||
const sidebarStateWritable = writable<SidebarState>(getInitialSidebarState());
|
||||
const initialSidebarState = getInitialSidebarState();
|
||||
const sidebarStateWritable = writable<SidebarState>(initialSidebarState);
|
||||
const themeWritable = writable<Theme>(getInitialTheme());
|
||||
|
||||
function syncSidebarAttribute(value: SidebarState): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.setAttribute('data-sidebar', value);
|
||||
}
|
||||
|
||||
syncSidebarAttribute(initialSidebarState);
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
sidebarStateWritable.subscribe((value) => {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, value);
|
||||
syncSidebarAttribute(value);
|
||||
});
|
||||
|
||||
themeWritable.subscribe((value) => {
|
||||
@@ -65,7 +96,7 @@ export const theme = {
|
||||
export function toggleSidebar(): void {
|
||||
sidebarStateWritable.update((current) => {
|
||||
if (current === 'expanded') return 'collapsed';
|
||||
if (current === 'collapsed') return 'hidden';
|
||||
if (current === 'collapsed') return 'expanded';
|
||||
return 'expanded';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user