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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user