mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 13:43:54 +08:00
feat: initialize monorepo with package.json for CCW workflow platform
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// ========================================
|
||||
// DashboardGridContainer Component
|
||||
// ========================================
|
||||
// Responsive grid layout using react-grid-layout for draggable/resizable widgets
|
||||
|
||||
import * as React from 'react';
|
||||
import { Responsive, WidthProvider, Layout as RGLLayout } from 'react-grid-layout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
|
||||
import { GRID_BREAKPOINTS, GRID_COLS, GRID_ROW_HEIGHT } from './defaultLayouts';
|
||||
import type { DashboardLayouts } from '@/types/store';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
export interface DashboardGridContainerProps {
|
||||
/** Child elements to render in the grid (widgets/sections) */
|
||||
children: React.ReactNode;
|
||||
/** Additional CSS classes for the grid container */
|
||||
className?: string;
|
||||
/** Whether grid items are draggable */
|
||||
isDraggable?: boolean;
|
||||
/** Whether grid items are resizable */
|
||||
isResizable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardGridContainer - Responsive grid layout with drag-drop support
|
||||
*
|
||||
* Uses react-grid-layout for draggable and resizable dashboard widgets.
|
||||
* Layouts are persisted to localStorage and Zustand store.
|
||||
*
|
||||
* Breakpoints:
|
||||
* - lg: >= 1024px (12 columns)
|
||||
* - md: >= 768px (6 columns)
|
||||
* - sm: >= 640px (2 columns)
|
||||
*/
|
||||
export function DashboardGridContainer({
|
||||
children,
|
||||
className,
|
||||
isDraggable = true,
|
||||
isResizable = true,
|
||||
}: DashboardGridContainerProps) {
|
||||
const { layouts, updateLayouts } = useUserDashboardLayout();
|
||||
|
||||
// Handle layout change (debounced via hook)
|
||||
const handleLayoutChange = React.useCallback(
|
||||
(_currentLayout: RGLLayout[], allLayouts: DashboardLayouts) => {
|
||||
updateLayouts(allLayouts);
|
||||
},
|
||||
[updateLayouts]
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={cn('dashboard-grid', className)}
|
||||
layouts={layouts}
|
||||
breakpoints={GRID_BREAKPOINTS}
|
||||
cols={GRID_COLS}
|
||||
rowHeight={GRID_ROW_HEIGHT}
|
||||
isDraggable={isDraggable}
|
||||
isResizable={isResizable}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
containerPadding={[0, 0]}
|
||||
margin={[16, 16]}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardGridContainer;
|
||||
83
ccw/frontend/src/components/dashboard/DashboardHeader.tsx
Normal file
83
ccw/frontend/src/components/dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// ========================================
|
||||
// DashboardHeader Component
|
||||
// ========================================
|
||||
// Reusable dashboard header with title, description, and refresh action
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DashboardHeaderProps {
|
||||
/** i18n key for the dashboard title */
|
||||
titleKey: string;
|
||||
/** i18n key for the dashboard description */
|
||||
descriptionKey: string;
|
||||
/** Callback when refresh button is clicked */
|
||||
onRefresh?: () => void;
|
||||
/** Whether the refresh action is currently loading */
|
||||
isRefreshing?: boolean;
|
||||
/** Callback when reset layout button is clicked */
|
||||
onResetLayout?: () => void;
|
||||
/** Optional additional actions to render */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardHeader - Reusable header component for dashboard pages
|
||||
*
|
||||
* Displays a title, description, and optional refresh/reset layout buttons.
|
||||
* Supports additional custom actions via the actions prop.
|
||||
*/
|
||||
export function DashboardHeader({
|
||||
titleKey,
|
||||
descriptionKey,
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
onResetLayout,
|
||||
actions,
|
||||
}: DashboardHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{formatMessage({ id: titleKey })}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: descriptionKey })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
{onResetLayout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onResetLayout}
|
||||
aria-label="Reset dashboard layout"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.resetLayout' })}
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={formatMessage({ id: 'home.dashboard.refreshTooltip' })}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isRefreshing && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardHeader;
|
||||
@@ -0,0 +1,436 @@
|
||||
// ========================================
|
||||
// Dashboard Integration Tests
|
||||
// ========================================
|
||||
// Integration tests for HomePage data flows: stats + sessions + charts + ticker all loading concurrently
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderWithI18n, screen, waitFor } from '@/test/i18n';
|
||||
import HomePage from '@/pages/HomePage';
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useDashboardStats', () => ({
|
||||
useDashboardStats: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useSessions', () => ({
|
||||
useSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useWorkflowStatusCounts', () => ({
|
||||
useWorkflowStatusCounts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useActivityTimeline', () => ({
|
||||
useActivityTimeline: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useTaskTypeCounts', () => ({
|
||||
useTaskTypeCounts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useRealtimeUpdates', () => ({
|
||||
useRealtimeUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useUserDashboardLayout', () => ({
|
||||
useUserDashboardLayout: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/appStore', () => ({
|
||||
useAppStore: vi.fn(() => ({
|
||||
projectPath: '/test/project',
|
||||
locale: 'en',
|
||||
})),
|
||||
}));
|
||||
|
||||
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { useWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
|
||||
import { useActivityTimeline } from '@/hooks/useActivityTimeline';
|
||||
import { useTaskTypeCounts } from '@/hooks/useTaskTypeCounts';
|
||||
import { useRealtimeUpdates } from '@/hooks/useRealtimeUpdates';
|
||||
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
|
||||
|
||||
describe('Dashboard Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
// Setup default mock responses
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: {
|
||||
totalSessions: 42,
|
||||
activeSessions: 5,
|
||||
completedToday: 12,
|
||||
averageTime: '2.5h',
|
||||
successRate: 85,
|
||||
taskCount: 156,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useSessions).mockReturnValue({
|
||||
activeSessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
name: 'Test Session 1',
|
||||
status: 'in_progress',
|
||||
tasks: [{ status: 'completed' }, { status: 'pending' }],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
archivedSessions: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useWorkflowStatusCounts).mockReturnValue({
|
||||
data: [
|
||||
{ status: 'completed', count: 30, percentage: 60 },
|
||||
{ status: 'in_progress', count: 10, percentage: 20 },
|
||||
{ status: 'pending', count: 10, percentage: 20 },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useActivityTimeline).mockReturnValue({
|
||||
data: [
|
||||
{ date: '2026-02-01', sessions: 5, tasks: 20 },
|
||||
{ date: '2026-02-02', sessions: 8, tasks: 35 },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useTaskTypeCounts).mockReturnValue({
|
||||
data: [
|
||||
{ type: 'feature', count: 45 },
|
||||
{ type: 'bugfix', count: 30 },
|
||||
{ type: 'refactor', count: 15 },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useRealtimeUpdates).mockReturnValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
text: 'Session completed',
|
||||
type: 'session',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
connectionStatus: 'connected',
|
||||
reconnect: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(useUserDashboardLayout).mockReturnValue({
|
||||
layouts: {
|
||||
lg: [],
|
||||
md: [],
|
||||
sm: [],
|
||||
},
|
||||
saveLayout: vi.fn(),
|
||||
resetLayout: vi.fn(),
|
||||
isSaving: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Concurrent Data Loading', () => {
|
||||
it('INT-1.1 - should load all data sources concurrently', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
// Verify all hooks are called
|
||||
expect(useDashboardStats).toHaveBeenCalled();
|
||||
expect(useSessions).toHaveBeenCalled();
|
||||
expect(useWorkflowStatusCounts).toHaveBeenCalled();
|
||||
expect(useActivityTimeline).toHaveBeenCalled();
|
||||
expect(useTaskTypeCounts).toHaveBeenCalled();
|
||||
expect(useRealtimeUpdates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('INT-1.2 - should display all widgets with loaded data', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for stat cards
|
||||
expect(screen.queryByText('42')).toBeInTheDocument(); // total sessions
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-1.3 - should handle loading states correctly', async () => {
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
// Should show loading skeleton
|
||||
await waitFor(() => {
|
||||
const skeletons = screen.queryAllByTestId(/skeleton/i);
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-1.4 - should handle partial loading states', async () => {
|
||||
// Stats loading, sessions loaded
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that hooks were called (rendering may vary based on implementation)
|
||||
expect(useDashboardStats).toHaveBeenCalled();
|
||||
expect(useSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Flow Integration', () => {
|
||||
it('INT-2.1 - should pass stats data to DetailedStatsWidget', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('42')).toBeInTheDocument();
|
||||
expect(screen.queryByText('5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-2.2 - should pass session data to RecentSessionsWidget', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Test Session 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-2.3 - should pass chart data to chart widgets', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Chart data should be rendered
|
||||
expect(useWorkflowStatusCounts).toHaveBeenCalled();
|
||||
expect(useActivityTimeline).toHaveBeenCalled();
|
||||
expect(useTaskTypeCounts).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-2.4 - should pass ticker messages to TickerMarquee', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useRealtimeUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('INT-3.1 - should display error state when stats hook fails', async () => {
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load stats'),
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorText = screen.queryByText(/error|failed/i);
|
||||
expect(errorText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-3.2 - should display error state when sessions hook fails', async () => {
|
||||
vi.mocked(useSessions).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load sessions'),
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorText = screen.queryByText(/error|failed/i);
|
||||
expect(errorText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-3.3 - should display error state when chart hooks fail', async () => {
|
||||
vi.mocked(useWorkflowStatusCounts).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load chart data'),
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useWorkflowStatusCounts).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-3.4 - should handle partial errors gracefully', async () => {
|
||||
// Only stats fails, others succeed
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Stats failed'),
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that useSessions was called (sessions may or may not render)
|
||||
expect(useSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-3.5 - should handle WebSocket disconnection', async () => {
|
||||
vi.mocked(useRealtimeUpdates).mockReturnValue({
|
||||
messages: [],
|
||||
connectionStatus: 'disconnected',
|
||||
reconnect: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useRealtimeUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Refresh', () => {
|
||||
it('INT-4.1 - should refresh all data sources on refresh button click', async () => {
|
||||
const mockRefetch = vi.fn();
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: { totalSessions: 42 } as any,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
} as any);
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
const refreshButton = screen.queryByRole('button', { name: /refresh/i });
|
||||
if (refreshButton) {
|
||||
refreshButton.click();
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('INT-4.2 - should update UI when data changes', async () => {
|
||||
const { rerender } = renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('42')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Update data
|
||||
vi.mocked(useDashboardStats).mockReturnValue({
|
||||
data: { totalSessions: 50 } as any,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
rerender(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('50')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workspace Scoping', () => {
|
||||
it('INT-5.1 - should pass workspace path to all data hooks', async () => {
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDashboardStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectPath: '/test/project' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-5.2 - should refresh data when workspace changes', async () => {
|
||||
const { rerender } = renderWithI18n(<HomePage />);
|
||||
|
||||
// Change workspace
|
||||
vi.mocked(require('@/stores/appStore').useAppStore).mockReturnValue({
|
||||
projectPath: '/different/project',
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
rerender(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDashboardStats).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Realtime Updates', () => {
|
||||
it('INT-6.1 - should display new ticker messages as they arrive', async () => {
|
||||
const { rerender } = renderWithI18n(<HomePage />);
|
||||
|
||||
// Add new message
|
||||
vi.mocked(useRealtimeUpdates).mockReturnValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-2',
|
||||
text: 'New session started',
|
||||
type: 'session',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
connectionStatus: 'connected',
|
||||
reconnect: vi.fn(),
|
||||
});
|
||||
|
||||
rerender(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useRealtimeUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('INT-6.2 - should maintain connection status indicator', async () => {
|
||||
vi.mocked(useRealtimeUpdates).mockReturnValue({
|
||||
messages: [],
|
||||
connectionStatus: 'reconnecting',
|
||||
reconnect: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithI18n(<HomePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useRealtimeUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
64
ccw/frontend/src/components/dashboard/defaultLayouts.ts
Normal file
64
ccw/frontend/src/components/dashboard/defaultLayouts.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// ========================================
|
||||
// Default Dashboard Layouts
|
||||
// ========================================
|
||||
// Default widget configurations and responsive layouts for the dashboard grid
|
||||
|
||||
import type { WidgetConfig, DashboardLayouts, DashboardLayoutState } from '@/types/store';
|
||||
|
||||
/** Widget IDs used across the dashboard */
|
||||
export const WIDGET_IDS = {
|
||||
STATS: 'detailed-stats',
|
||||
RECENT_SESSIONS: 'recent-sessions',
|
||||
WORKFLOW_STATUS: 'workflow-status-pie',
|
||||
ACTIVITY: 'activity-line',
|
||||
TASK_TYPES: 'task-type-bar',
|
||||
} as const;
|
||||
|
||||
/** Default widget configurations */
|
||||
export const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||
{ i: WIDGET_IDS.STATS, name: 'Statistics', visible: true, minW: 4, minH: 2 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, name: 'Recent Sessions', visible: true, minW: 4, minH: 3 },
|
||||
{ i: WIDGET_IDS.WORKFLOW_STATUS, name: 'Workflow Status', visible: true, minW: 3, minH: 3 },
|
||||
{ i: WIDGET_IDS.ACTIVITY, name: 'Activity', visible: true, minW: 4, minH: 3 },
|
||||
{ i: WIDGET_IDS.TASK_TYPES, name: 'Task Types', visible: true, minW: 3, minH: 3 },
|
||||
];
|
||||
|
||||
/** Default responsive layouts */
|
||||
export const DEFAULT_LAYOUTS: DashboardLayouts = {
|
||||
lg: [
|
||||
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 12, h: 2, minW: 4, minH: 2 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 4, minH: 3 },
|
||||
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 6, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
|
||||
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 6, w: 7, h: 4, minW: 4, minH: 3 },
|
||||
{ i: WIDGET_IDS.TASK_TYPES, x: 7, y: 6, w: 5, h: 4, minW: 3, minH: 3 },
|
||||
],
|
||||
md: [
|
||||
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 6, h: 2, minW: 3, minH: 2 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
|
||||
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 6, w: 6, h: 4, minW: 3, minH: 3 },
|
||||
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 10, w: 6, h: 4, minW: 3, minH: 3 },
|
||||
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 14, w: 6, h: 4, minW: 3, minH: 3 },
|
||||
],
|
||||
sm: [
|
||||
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 2, h: 3, minW: 2, minH: 2 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 3, w: 2, h: 4, minW: 2, minH: 3 },
|
||||
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 7, w: 2, h: 4, minW: 2, minH: 3 },
|
||||
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 11, w: 2, h: 4, minW: 2, minH: 3 },
|
||||
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 15, w: 2, h: 4, minW: 2, minH: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Default dashboard layout state */
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutState = {
|
||||
widgets: DEFAULT_WIDGETS,
|
||||
layouts: DEFAULT_LAYOUTS,
|
||||
};
|
||||
|
||||
/** Grid breakpoints matching Tailwind config */
|
||||
export const GRID_BREAKPOINTS = { lg: 1024, md: 768, sm: 640 };
|
||||
|
||||
/** Grid columns per breakpoint */
|
||||
export const GRID_COLS = { lg: 12, md: 6, sm: 2 };
|
||||
|
||||
/** Row height in pixels */
|
||||
export const GRID_ROW_HEIGHT = 60;
|
||||
@@ -0,0 +1,58 @@
|
||||
// ========================================
|
||||
// ActivityLineChartWidget Component
|
||||
// ========================================
|
||||
// Widget wrapper for activity line chart
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ActivityLineChart, ChartSkeleton } from '@/components/charts';
|
||||
import { useActivityTimeline, generateMockActivityTimeline } from '@/hooks/useActivityTimeline';
|
||||
|
||||
export interface ActivityLineChartWidgetProps {
|
||||
/** Data grid attributes for react-grid-layout */
|
||||
'data-grid'?: {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityLineChartWidget - Dashboard widget showing activity trends over time
|
||||
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
|
||||
*/
|
||||
function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineChartWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading, error } = useActivityTimeline();
|
||||
|
||||
// Use mock data if API is not ready
|
||||
const chartData = data || generateMockActivityTimeline();
|
||||
|
||||
return (
|
||||
<div {...props} className={className}>
|
||||
<Card className="h-full p-4 flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'home.widgets.activity' })}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<ChartSkeleton type="line" height={280} />
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load chart data</p>
|
||||
</div>
|
||||
) : (
|
||||
<ActivityLineChart data={chartData} height={280} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ActivityLineChartWidget = memo(ActivityLineChartWidgetComponent);
|
||||
|
||||
export default ActivityLineChartWidget;
|
||||
@@ -0,0 +1,150 @@
|
||||
// ========================================
|
||||
// DetailedStatsWidget Component
|
||||
// ========================================
|
||||
// Widget wrapper for detailed statistics cards in dashboard grid layout
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
FolderKanban,
|
||||
ListChecks,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
|
||||
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
||||
|
||||
export interface DetailedStatsWidgetProps {
|
||||
/** Data grid attributes for react-grid-layout */
|
||||
'data-grid'?: {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DetailedStatsWidget - Dashboard widget showing detailed statistics
|
||||
*
|
||||
* Displays 6 stat cards with key metrics:
|
||||
* - Active sessions, total tasks, completed tasks
|
||||
* - Pending tasks, failed tasks, today's activity
|
||||
*
|
||||
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
|
||||
*/
|
||||
function DetailedStatsWidgetComponent({ className, ...props }: DetailedStatsWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Fetch dashboard stats
|
||||
const { stats, isLoading, isFetching } = useDashboardStats({
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
|
||||
// Generate mock sparkline data for last 7 days
|
||||
// TODO: Replace with real API data when backend provides trend data
|
||||
const generateSparklineData = (currentValue: number, variance = 0.3): number[] => {
|
||||
const days = 7;
|
||||
const data: number[] = [];
|
||||
let value = Math.max(0, currentValue * (1 - variance));
|
||||
|
||||
for (let i = 0; i < days - 1; i++) {
|
||||
data.push(Math.round(value));
|
||||
const change = (Math.random() - 0.5) * 2 * variance * currentValue;
|
||||
value = Math.max(0, value + change);
|
||||
}
|
||||
|
||||
// Last day is current value
|
||||
data.push(currentValue);
|
||||
return data;
|
||||
};
|
||||
|
||||
// Stat card configuration with sparkline data
|
||||
const statCards = React.useMemo(() => [
|
||||
{
|
||||
key: 'activeSessions',
|
||||
title: formatMessage({ id: 'home.stats.activeSessions' }),
|
||||
icon: FolderKanban,
|
||||
variant: 'primary' as const,
|
||||
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
|
||||
getSparkline: (stats: { activeSessions: number }) => generateSparklineData(stats.activeSessions, 0.4),
|
||||
},
|
||||
{
|
||||
key: 'totalTasks',
|
||||
title: formatMessage({ id: 'home.stats.totalTasks' }),
|
||||
icon: ListChecks,
|
||||
variant: 'info' as const,
|
||||
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
|
||||
getSparkline: (stats: { totalTasks: number }) => generateSparklineData(stats.totalTasks, 0.3),
|
||||
},
|
||||
{
|
||||
key: 'completedTasks',
|
||||
title: formatMessage({ id: 'home.stats.completedTasks' }),
|
||||
icon: CheckCircle2,
|
||||
variant: 'success' as const,
|
||||
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
|
||||
getSparkline: (stats: { completedTasks: number }) => generateSparklineData(stats.completedTasks, 0.25),
|
||||
},
|
||||
{
|
||||
key: 'pendingTasks',
|
||||
title: formatMessage({ id: 'home.stats.pendingTasks' }),
|
||||
icon: Clock,
|
||||
variant: 'warning' as const,
|
||||
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
|
||||
getSparkline: (stats: { pendingTasks: number }) => generateSparklineData(stats.pendingTasks, 0.35),
|
||||
},
|
||||
{
|
||||
key: 'failedTasks',
|
||||
title: formatMessage({ id: 'common.status.failed' }),
|
||||
icon: XCircle,
|
||||
variant: 'danger' as const,
|
||||
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
|
||||
getSparkline: (stats: { failedTasks: number }) => generateSparklineData(stats.failedTasks, 0.5),
|
||||
},
|
||||
{
|
||||
key: 'todayActivity',
|
||||
title: formatMessage({ id: 'common.stats.todayActivity' }),
|
||||
icon: Activity,
|
||||
variant: 'default' as const,
|
||||
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
|
||||
getSparkline: (stats: { todayActivity: number }) => generateSparklineData(stats.todayActivity, 0.6),
|
||||
},
|
||||
], [formatMessage]);
|
||||
|
||||
return (
|
||||
<div {...props} className={className}>
|
||||
<Card className="h-full p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
: statCards.map((card) => (
|
||||
<StatCard
|
||||
key={card.key}
|
||||
title={card.title}
|
||||
value={stats ? card.getValue(stats as any) : 0}
|
||||
icon={card.icon}
|
||||
variant={card.variant}
|
||||
isLoading={isFetching && !stats}
|
||||
sparklineData={stats ? (card as any).getSparkline(stats as any) : undefined}
|
||||
showSparkline={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized DetailedStatsWidget - Prevents re-renders when parent updates
|
||||
* Props are compared shallowly; use useCallback for function props
|
||||
*/
|
||||
export const DetailedStatsWidget = React.memo(DetailedStatsWidgetComponent);
|
||||
|
||||
export default DetailedStatsWidget;
|
||||
@@ -0,0 +1,117 @@
|
||||
// ========================================
|
||||
// RecentSessionsWidget Component
|
||||
// ========================================
|
||||
// Widget wrapper for recent sessions list in dashboard grid layout
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface RecentSessionsWidgetProps {
|
||||
/** Data grid attributes for react-grid-layout */
|
||||
'data-grid'?: {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Maximum number of sessions to display */
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RecentSessionsWidget - Dashboard widget showing recent workflow sessions
|
||||
*
|
||||
* Displays recent active sessions (max 6 by default) with navigation to session detail.
|
||||
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
|
||||
*/
|
||||
function RecentSessionsWidgetComponent({
|
||||
className,
|
||||
maxSessions = 6,
|
||||
...props
|
||||
}: RecentSessionsWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch recent sessions (active only)
|
||||
const { activeSessions, isLoading } = useSessions({
|
||||
filter: { location: 'active' },
|
||||
});
|
||||
|
||||
// Get recent sessions (sorted by creation date)
|
||||
const recentSessions = React.useMemo(
|
||||
() =>
|
||||
[...activeSessions]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, maxSessions),
|
||||
[activeSessions, maxSessions]
|
||||
);
|
||||
|
||||
const handleSessionClick = (sessionId: string) => {
|
||||
navigate(`/sessions/${sessionId}`);
|
||||
};
|
||||
|
||||
const handleViewAll = () => {
|
||||
navigate('/sessions');
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...props} className={className}>
|
||||
<Card className="h-full p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'home.sections.recentSessions' })}
|
||||
</h3>
|
||||
<Button variant="link" size="sm" onClick={handleViewAll}>
|
||||
{formatMessage({ id: 'common.actions.viewAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SessionCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : recentSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'home.emptyState.noSessions.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
onClick={handleSessionClick}
|
||||
onView={handleSessionClick}
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized RecentSessionsWidget - Prevents re-renders when parent updates
|
||||
* Props are compared shallowly; use useCallback for function props
|
||||
*/
|
||||
export const RecentSessionsWidget = React.memo(RecentSessionsWidgetComponent);
|
||||
|
||||
export default RecentSessionsWidget;
|
||||
@@ -0,0 +1,58 @@
|
||||
// ========================================
|
||||
// TaskTypeBarChartWidget Component
|
||||
// ========================================
|
||||
// Widget wrapper for task type bar chart
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TaskTypeBarChart, ChartSkeleton } from '@/components/charts';
|
||||
import { useTaskTypeCounts, generateMockTaskTypeCounts } from '@/hooks/useTaskTypeCounts';
|
||||
|
||||
export interface TaskTypeBarChartWidgetProps {
|
||||
/** Data grid attributes for react-grid-layout */
|
||||
'data-grid'?: {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskTypeBarChartWidget - Dashboard widget showing task type distribution
|
||||
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
|
||||
*/
|
||||
function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarChartWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading, error } = useTaskTypeCounts();
|
||||
|
||||
// Use mock data if API is not ready
|
||||
const chartData = data || generateMockTaskTypeCounts();
|
||||
|
||||
return (
|
||||
<div {...props} className={className}>
|
||||
<Card className="h-full p-4 flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'home.widgets.taskTypes' })}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<ChartSkeleton type="bar" height={280} />
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load chart data</p>
|
||||
</div>
|
||||
) : (
|
||||
<TaskTypeBarChart data={chartData} height={280} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskTypeBarChartWidget = memo(TaskTypeBarChartWidgetComponent);
|
||||
|
||||
export default TaskTypeBarChartWidget;
|
||||
@@ -0,0 +1,58 @@
|
||||
// ========================================
|
||||
// WorkflowStatusPieChartWidget Component
|
||||
// ========================================
|
||||
// Widget wrapper for workflow status pie chart
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { WorkflowStatusPieChart, ChartSkeleton } from '@/components/charts';
|
||||
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
|
||||
|
||||
export interface WorkflowStatusPieChartWidgetProps {
|
||||
/** Data grid attributes for react-grid-layout */
|
||||
'data-grid'?: {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkflowStatusPieChartWidget - Dashboard widget showing workflow status distribution
|
||||
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
|
||||
*/
|
||||
function WorkflowStatusPieChartWidgetComponent({ className, ...props }: WorkflowStatusPieChartWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading, error } = useWorkflowStatusCounts();
|
||||
|
||||
// Use mock data if API is not ready
|
||||
const chartData = data || generateMockWorkflowStatusCounts();
|
||||
|
||||
return (
|
||||
<div {...props} className={className}>
|
||||
<Card className="h-full p-4 flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'home.widgets.workflowStatus' })}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<ChartSkeleton type="pie" height={280} />
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load chart data</p>
|
||||
</div>
|
||||
) : (
|
||||
<WorkflowStatusPieChart data={chartData} height={280} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const WorkflowStatusPieChartWidget = memo(WorkflowStatusPieChartWidgetComponent);
|
||||
|
||||
export default WorkflowStatusPieChartWidget;
|
||||
19
ccw/frontend/src/components/dashboard/widgets/index.ts
Normal file
19
ccw/frontend/src/components/dashboard/widgets/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// ========================================
|
||||
// Dashboard Widgets - Export Index
|
||||
// ========================================
|
||||
// Central export point for all dashboard widget components
|
||||
|
||||
export { DetailedStatsWidget } from './DetailedStatsWidget';
|
||||
export type { DetailedStatsWidgetProps } from './DetailedStatsWidget';
|
||||
|
||||
export { RecentSessionsWidget } from './RecentSessionsWidget';
|
||||
export type { RecentSessionsWidgetProps } from './RecentSessionsWidget';
|
||||
|
||||
export { WorkflowStatusPieChartWidget } from './WorkflowStatusPieChartWidget';
|
||||
export type { WorkflowStatusPieChartWidgetProps } from './WorkflowStatusPieChartWidget';
|
||||
|
||||
export { ActivityLineChartWidget } from './ActivityLineChartWidget';
|
||||
export type { ActivityLineChartWidgetProps } from './ActivityLineChartWidget';
|
||||
|
||||
export { TaskTypeBarChartWidget } from './TaskTypeBarChartWidget';
|
||||
export type { TaskTypeBarChartWidgetProps } from './TaskTypeBarChartWidget';
|
||||
Reference in New Issue
Block a user