Task 5.25: Dashboard-System — GET /api/v1/dashboard/widgets, DashboardWidgetLoader, DashboardGrid with drag-and-drop, 3 example widgets (RecentContacts, TasksSummary, CalendarUpcoming), updated Dashboard.tsx, i18n, 6 tests
This commit is contained in:
@@ -30,6 +30,7 @@ from app.routes import (
|
|||||||
auth,
|
auth,
|
||||||
contact_folders,
|
contact_folders,
|
||||||
contacts,
|
contacts,
|
||||||
|
dashboard,
|
||||||
entity_history,
|
entity_history,
|
||||||
groups,
|
groups,
|
||||||
health,
|
health,
|
||||||
@@ -293,6 +294,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(contacts.router)
|
app.include_router(contacts.router)
|
||||||
app.include_router(contact_folders.router)
|
app.include_router(contact_folders.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
app.include_router(entity_history.router)
|
app.include_router(entity_history.router)
|
||||||
app.include_router(import_export.router)
|
app.include_router(import_export.router)
|
||||||
app.include_router(plugins.router)
|
app.include_router(plugins.router)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.routes import (
|
|||||||
audit, # noqa: F401
|
audit, # noqa: F401
|
||||||
auth, # noqa: F401
|
auth, # noqa: F401
|
||||||
contacts, # noqa: F401
|
contacts, # noqa: F401
|
||||||
|
dashboard, # noqa: F401
|
||||||
entity_history, # noqa: F401
|
entity_history, # noqa: F401
|
||||||
currencies, # noqa: F401
|
currencies, # noqa: F401
|
||||||
taxes, # noqa: F401
|
taxes, # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Dashboard routes — list available widgets from active plugins (Task 5.25)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import require_permission
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/widgets")
|
||||||
|
async def list_dashboard_widgets(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("dashboard:read")),
|
||||||
|
):
|
||||||
|
"""List all available dashboard widgets from active plugins.
|
||||||
|
|
||||||
|
Returns a flat list of widget definitions contributed by active plugins,
|
||||||
|
sorted by their order field. Each widget includes the contributing plugin name.
|
||||||
|
"""
|
||||||
|
registry = get_registry()
|
||||||
|
manifests = await registry.get_active_manifests(db)
|
||||||
|
|
||||||
|
widgets: list[dict] = []
|
||||||
|
for manifest in manifests:
|
||||||
|
for widget in manifest.get("dashboard_widgets", []):
|
||||||
|
widget_copy = dict(widget)
|
||||||
|
widget_copy["plugin_name"] = manifest["name"]
|
||||||
|
widgets.append(widget_copy)
|
||||||
|
|
||||||
|
# Sort by order field
|
||||||
|
widgets.sort(key=lambda w: w.get("order", 100))
|
||||||
|
|
||||||
|
return {"items": widgets, "total": len(widgets)}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard tests — Task 5.25.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { DashboardGrid } from '@/components/dashboard/DashboardGrid';
|
||||||
|
import type { DashboardWidgetDef } from '@/api/dashboard';
|
||||||
|
|
||||||
|
// Mock i18n
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock DashboardWidgetLoader to avoid lazy loading in tests
|
||||||
|
vi.mock('@/components/dashboard/DashboardWidgetLoader', () => ({
|
||||||
|
DashboardWidgetLoader: ({ widget }: { widget: DashboardWidgetDef }) => (
|
||||||
|
<div data-testid={`widget-${widget.id}`}>Widget: {widget.label}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Skeleton
|
||||||
|
vi.mock('@/components/ui/Skeleton', () => ({
|
||||||
|
Skeleton: ({ className }: { className?: string }) => (
|
||||||
|
<div className={className} data-testid="skeleton">Loading...</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockWidgets: DashboardWidgetDef[] = [
|
||||||
|
{
|
||||||
|
id: 'recent_contacts',
|
||||||
|
label_key: 'dashboard.recentContacts',
|
||||||
|
label: 'Recent Contacts',
|
||||||
|
component: '@/components/dashboard/RecentContactsWidget',
|
||||||
|
icon: 'Users',
|
||||||
|
order: 10,
|
||||||
|
col_span: 2,
|
||||||
|
row_span: 1,
|
||||||
|
permission: '',
|
||||||
|
plugin_name: 'contacts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tasks_summary',
|
||||||
|
label_key: 'dashboard.tasksSummary',
|
||||||
|
label: 'Tasks Summary',
|
||||||
|
component: '@/components/dashboard/TasksSummaryWidget',
|
||||||
|
icon: 'CheckSquare',
|
||||||
|
order: 20,
|
||||||
|
col_span: 1,
|
||||||
|
row_span: 1,
|
||||||
|
permission: '',
|
||||||
|
plugin_name: 'tasks',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('DashboardGrid', () => {
|
||||||
|
it('renders widgets in a grid', () => {
|
||||||
|
render(<DashboardGrid widgets={mockWidgets} />);
|
||||||
|
expect(screen.getByTestId('dashboard-grid')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('dashboard-grid-item-recent_contacts')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('dashboard-grid-item-tasks_summary')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty message when no widgets', () => {
|
||||||
|
render(<DashboardGrid widgets={[]} />);
|
||||||
|
expect(screen.getByTestId('dashboard-grid-empty')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders widget labels', () => {
|
||||||
|
render(<DashboardGrid widgets={mockWidgets} />);
|
||||||
|
expect(screen.getByText('Recent Contacts')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tasks Summary')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard API hooks (Task 5.25).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiGet } from './client';
|
||||||
|
|
||||||
|
export interface DashboardWidgetDef {
|
||||||
|
id: string;
|
||||||
|
label_key: string;
|
||||||
|
label: string;
|
||||||
|
component: string;
|
||||||
|
icon: string;
|
||||||
|
order: number;
|
||||||
|
col_span: number;
|
||||||
|
row_span: number;
|
||||||
|
permission: string;
|
||||||
|
plugin_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardWidgetsResponse {
|
||||||
|
items: DashboardWidgetDef[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardWidgets() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dashboardWidgets'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<DashboardWidgetsResponse>('/dashboard/widgets'),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -40,9 +40,9 @@ export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => v
|
|||||||
const overrides: Record<string, unknown> = {};
|
const overrides: Record<string, unknown> = {};
|
||||||
for (const [field, value] of Object.entries(fieldOverrides)) {
|
for (const [field, value] of Object.entries(fieldOverrides)) {
|
||||||
if (value === 'source') {
|
if (value === 'source') {
|
||||||
overrides[field] = (pair.source_contact as Record<string, unknown>)[field];
|
overrides[field] = (pair.source_contact as unknown as Record<string, unknown>)[field];
|
||||||
} else if (value === 'target') {
|
} else if (value === 'target') {
|
||||||
overrides[field] = (pair.target_contact as Record<string, unknown>)[field];
|
overrides[field] = (pair.target_contact as unknown as Record<string, unknown>)[field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => v
|
|||||||
<div className="text-center">{t('dedup.target')}</div>
|
<div className="text-center">{t('dedup.target')}</div>
|
||||||
</div>
|
</div>
|
||||||
{compareFields.map((field) => {
|
{compareFields.map((field) => {
|
||||||
const sourceVal = (pair.source_contact as Record<string, unknown>)[field.key] as string | null;
|
const sourceVal = (pair.source_contact as unknown as Record<string, unknown>)[field.key] as string | null;
|
||||||
const targetVal = (pair.target_contact as Record<string, unknown>)[field.key] as string | null;
|
const targetVal = (pair.target_contact as unknown as Record<string, unknown>)[field.key] as string | null;
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
|
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
|
||||||
<div className="text-secondary-700">{field.label}</div>
|
<div className="text-secondary-700">{field.label}</div>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* CalendarUpcomingWidget — shows next 3 upcoming events (Task 5.25).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { listEntries, type CalendarEntry } from '@/api/calendar';
|
||||||
|
import { formatDateTime } from '@/utils/date';
|
||||||
|
import { Calendar, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
export function CalendarUpcomingWidget() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['calendarEntries', 'upcoming'],
|
||||||
|
queryFn: () => listEntries(),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="animate-pulse space-y-2" data-testid="calendar-upcoming-loading">{[...Array(2)].map((_, i) => <div key={i} className="h-4 bg-secondary-100 rounded" />)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="text-sm text-secondary-500" data-testid="calendar-upcoming-error">{t('dashboard.widgetError')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: CalendarEntry[] = data ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
const upcoming = entries
|
||||||
|
.filter((e: CalendarEntry) => new Date(e.start_at || e.due_date || '') >= now)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (upcoming.length === 0) {
|
||||||
|
return <p className="text-sm text-secondary-500" data-testid="calendar-upcoming-empty">{t('dashboard.noUpcomingEvents')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" data-testid="calendar-upcoming-widget">
|
||||||
|
{upcoming.map((entry: CalendarEntry) => (
|
||||||
|
<div key={entry.id} className="flex items-start gap-2 text-sm">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-primary-500 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-secondary-800">{entry.title || '—'}</div>
|
||||||
|
<div className="text-secondary-500 text-xs">
|
||||||
|
{formatDateTime(entry.start_at || entry.due_date) || ''}
|
||||||
|
</div>
|
||||||
|
{entry.location && (
|
||||||
|
<div className="flex items-center gap-1 text-secondary-400 text-xs mt-0.5">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{entry.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* DashboardGrid — grid layout with drag-and-drop widget positioning (Task 5.25).
|
||||||
|
* Uses native HTML5 drag-and-drop with CSS Grid — no heavy DnD library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { DashboardWidgetLoader } from './DashboardWidgetLoader';
|
||||||
|
import type { DashboardWidgetDef } from '@/api/dashboard';
|
||||||
|
|
||||||
|
interface DashboardGridProps {
|
||||||
|
widgets: DashboardWidgetDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardGrid({ widgets: initialWidgets }: DashboardGridProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [widgets, setWidgets] = useState(initialWidgets);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
setDragIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((index: number) => {
|
||||||
|
if (dragIndex === null || dragIndex === index) {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWidgets((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(dragIndex, 1);
|
||||||
|
next.splice(index, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, [dragIndex]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (widgets.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-secondary-500" data-testid="dashboard-grid-empty">
|
||||||
|
{t('dashboard.noWidgets')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||||
|
data-testid="dashboard-grid"
|
||||||
|
>
|
||||||
|
{widgets.map((widget, index) => {
|
||||||
|
const colSpan = Math.min(widget.col_span || 1, 4);
|
||||||
|
const colSpanClass = {
|
||||||
|
1: 'lg:col-span-1',
|
||||||
|
2: 'lg:col-span-2',
|
||||||
|
3: 'lg:col-span-3',
|
||||||
|
4: 'lg:col-span-4',
|
||||||
|
}[colSpan] || 'lg:col-span-1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${widget.plugin_name}-${widget.id}`}
|
||||||
|
className={`${colSpanClass} ${
|
||||||
|
dragIndex === index ? 'opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
dragOverIndex === index ? 'ring-2 ring-primary-400 rounded-lg' : ''
|
||||||
|
}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={() => handleDrop(index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
data-testid={`dashboard-grid-item-${widget.id}`}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-secondary-700">
|
||||||
|
{widget.label || t(widget.label_key)}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-secondary-400 cursor-move" title={t('dashboard.dragToReorder')}>⋮⋮</span>
|
||||||
|
</div>
|
||||||
|
<DashboardWidgetLoader widget={widget} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* DashboardWidgetLoader — dynamically loads widget components (Task 5.25).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { lazy, Suspense } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import type { DashboardWidgetDef } from '@/api/dashboard';
|
||||||
|
|
||||||
|
// Widget component registry — maps component paths to lazy-loaded components
|
||||||
|
const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||||
|
'@/components/dashboard/RecentContactsWidget': lazy(() =>
|
||||||
|
import('@/components/dashboard/RecentContactsWidget').then(m => ({ default: m.RecentContactsWidget }))
|
||||||
|
),
|
||||||
|
'@/components/dashboard/TasksSummaryWidget': lazy(() =>
|
||||||
|
import('@/components/dashboard/TasksSummaryWidget').then(m => ({ default: m.TasksSummaryWidget }))
|
||||||
|
),
|
||||||
|
'@/components/dashboard/CalendarUpcomingWidget': lazy(() =>
|
||||||
|
import('@/components/dashboard/CalendarUpcomingWidget').then(m => ({ default: m.CalendarUpcomingWidget }))
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DashboardWidgetLoaderProps {
|
||||||
|
widget: DashboardWidgetDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWidgetLoader({ widget }: DashboardWidgetLoaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const WidgetComponent = widgetRegistry[widget.component];
|
||||||
|
|
||||||
|
if (!WidgetComponent) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded-lg bg-secondary-50" data-testid={`widget-${widget.id}`}>
|
||||||
|
<p className="text-sm text-secondary-500">
|
||||||
|
{t('dashboard.widgetNotAvailable', { name: widget.label || widget.id })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid={`widget-${widget.id}`}>
|
||||||
|
<Suspense fallback={<Skeleton className="h-32" />}>
|
||||||
|
<WidgetComponent />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* RecentContactsWidget — shows last 5 contacts (Task 5.25).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useUnifiedContacts } from '@/api/hooks';
|
||||||
|
import { formatDateTime } from '@/utils/date';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
|
|
||||||
|
export function RecentContactsWidget() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError } = useUnifiedContacts(1, 5, undefined, undefined);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="animate-pulse space-y-2" data-testid="recent-contacts-loading">{[...Array(3)].map((_, i) => <div key={i} className="h-4 bg-secondary-100 rounded" />)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="text-sm text-secondary-500" data-testid="recent-contacts-error">{t('dashboard.widgetError')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = data?.items ?? [];
|
||||||
|
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
return <p className="text-sm text-secondary-500" data-testid="recent-contacts-empty">{t('dashboard.noContacts')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" data-testid="recent-contacts-widget">
|
||||||
|
{contacts.map((contact) => (
|
||||||
|
<div key={contact.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-3.5 h-3.5 text-secondary-400" />
|
||||||
|
<span className="font-medium text-secondary-800">{contact.displayname}</span>
|
||||||
|
{contact.email_1 && <span className="text-secondary-500 truncate">{contact.email_1}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* TasksSummaryWidget — shows open tasks count (Task 5.25).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTasks } from '@/api/tasks';
|
||||||
|
import { CheckSquare, AlertCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
export function TasksSummaryWidget() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError } = useTasks(1, 100);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="animate-pulse h-8 bg-secondary-100 rounded" data-testid="tasks-summary-loading" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="text-sm text-secondary-500" data-testid="tasks-summary-error">{t('dashboard.widgetError')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = data?.items ?? [];
|
||||||
|
const openTasks = tasks.filter((task) => task.status !== 'done');
|
||||||
|
const overdueTasks = tasks.filter((task) => {
|
||||||
|
if (!task.due_date || task.status === 'done') return false;
|
||||||
|
return new Date(task.due_date) < new Date();
|
||||||
|
});
|
||||||
|
const highPriority = openTasks.filter((task) => task.priority === 'high' || task.priority === 'urgent');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" data-testid="tasks-summary-widget">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckSquare className="w-5 h-5 text-primary-600" />
|
||||||
|
<span className="text-2xl font-bold text-secondary-900">{openTasks.length}</span>
|
||||||
|
<span className="text-sm text-secondary-500">{t('dashboard.openTasks')}</span>
|
||||||
|
</div>
|
||||||
|
{overdueTasks.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-danger-600">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span>{overdueTasks.length} {t('dashboard.overdueTasks')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{highPriority.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-warning-600">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{highPriority.length} {t('dashboard.highPriorityTasks')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{openTasks.length === 0 && (
|
||||||
|
<p className="text-sm text-secondary-500">{t('dashboard.noOpenTasks')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -95,7 +95,19 @@
|
|||||||
"statCompanies": "Firmen",
|
"statCompanies": "Firmen",
|
||||||
"statContacts": "Kontakte",
|
"statContacts": "Kontakte",
|
||||||
"statTasks": "Offene Aufgaben",
|
"statTasks": "Offene Aufgaben",
|
||||||
"statEmails": "E-Mails heute"
|
"statEmails": "E-Mails heute",
|
||||||
|
"widgets": "Widgets",
|
||||||
|
"widgetsUnavailable": "Widgets sind derzeit nicht verfügbar.",
|
||||||
|
"widgetNotAvailable": "Widget {{name}} ist nicht verfügbar.",
|
||||||
|
"widgetError": "Fehler beim Laden des Widgets.",
|
||||||
|
"noWidgets": "Keine Widgets verfügbar.",
|
||||||
|
"dragToReorder": "Ziehen zum Sortieren",
|
||||||
|
"noContacts": "Keine Kontakte vorhanden.",
|
||||||
|
"openTasks": "offene Aufgaben",
|
||||||
|
"overdueTasks": "überfällig",
|
||||||
|
"highPriorityTasks": "hohe Priorität",
|
||||||
|
"noOpenTasks": "Keine offenen Aufgaben.",
|
||||||
|
"noUpcomingEvents": "Keine anstehenden Termine."
|
||||||
},
|
},
|
||||||
"companies": {
|
"companies": {
|
||||||
"title": "Firmen",
|
"title": "Firmen",
|
||||||
|
|||||||
@@ -95,7 +95,19 @@
|
|||||||
"statCompanies": "Companies",
|
"statCompanies": "Companies",
|
||||||
"statContacts": "Contacts",
|
"statContacts": "Contacts",
|
||||||
"statTasks": "Open Tasks",
|
"statTasks": "Open Tasks",
|
||||||
"statEmails": "Emails Today"
|
"statEmails": "Emails Today",
|
||||||
|
"widgets": "Widgets",
|
||||||
|
"widgetsUnavailable": "Widgets are currently unavailable.",
|
||||||
|
"widgetNotAvailable": "Widget {{name}} is not available.",
|
||||||
|
"widgetError": "Error loading widget.",
|
||||||
|
"noWidgets": "No widgets available.",
|
||||||
|
"dragToReorder": "Drag to reorder",
|
||||||
|
"noContacts": "No contacts found.",
|
||||||
|
"openTasks": "open tasks",
|
||||||
|
"overdueTasks": "overdue",
|
||||||
|
"highPriorityTasks": "high priority",
|
||||||
|
"noOpenTasks": "No open tasks.",
|
||||||
|
"noUpcomingEvents": "No upcoming events."
|
||||||
},
|
},
|
||||||
"companies": {
|
"companies": {
|
||||||
"title": "Companies",
|
"title": "Companies",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { StatCard } from '@/components/shared/StatCard';
|
import { StatCard } from '@/components/shared/StatCard';
|
||||||
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
||||||
|
import { DashboardGrid } from '@/components/dashboard/DashboardGrid';
|
||||||
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
|
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
|
||||||
|
import { useDashboardWidgets } from '@/api/dashboard';
|
||||||
import { formatDateTime } from '@/utils/date';
|
import { formatDateTime } from '@/utils/date';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
@@ -10,6 +12,7 @@ export function DashboardPage() {
|
|||||||
const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
|
const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
|
||||||
const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person');
|
const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person');
|
||||||
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
|
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
|
||||||
|
const { data: widgetsData, isError: widgetsError } = useDashboardWidgets();
|
||||||
|
|
||||||
const totalCompanies = companiesData?.total ?? 0;
|
const totalCompanies = companiesData?.total ?? 0;
|
||||||
const totalContacts = contactsData?.total ?? 0;
|
const totalContacts = contactsData?.total ?? 0;
|
||||||
@@ -41,6 +44,8 @@ export function DashboardPage() {
|
|||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const widgets = widgetsData?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
|
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
|
||||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
|
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
|
||||||
@@ -68,6 +73,18 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Plugin Widgets */}
|
||||||
|
{widgetsError ? (
|
||||||
|
<p className="text-sm text-secondary-500 mb-6" data-testid="dashboard-widgets-unavailable">
|
||||||
|
{t('dashboard.widgetsUnavailable')}
|
||||||
|
</p>
|
||||||
|
) : widgets.length > 0 ? (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-800 mb-4">{t('dashboard.widgets')}</h2>
|
||||||
|
<DashboardGrid widgets={widgets} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{auditError ? (
|
{auditError ? (
|
||||||
<p className="text-sm text-secondary-500" data-testid="activity-unavailable">
|
<p className="text-sm text-secondary-500" data-testid="activity-unavailable">
|
||||||
{t('dashboard.activityUnavailable')}
|
{t('dashboard.activityUnavailable')}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Dashboard widget API tests — Task 5.25."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestDashboardWidgets:
|
||||||
|
"""GET /api/v1/dashboard/widgets — list available widgets."""
|
||||||
|
|
||||||
|
async def test_list_widgets_returns_200(self, client: AsyncClient, db_session):
|
||||||
|
"""Dashboard widgets endpoint returns 200 with items list."""
|
||||||
|
await seed_tenant_and_users(db_session)
|
||||||
|
await login_client(client, "admin@tenanta.com")
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/dashboard/widgets", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
|
||||||
|
async def test_list_widgets_requires_auth(self, client: AsyncClient, db_session):
|
||||||
|
"""Dashboard widgets endpoint requires authentication."""
|
||||||
|
await seed_tenant_and_users(db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/dashboard/widgets", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_list_widgets_has_plugin_name(self, client: AsyncClient, db_session):
|
||||||
|
"""Each widget should include the contributing plugin name."""
|
||||||
|
await seed_tenant_and_users(db_session)
|
||||||
|
await login_client(client, "admin@tenanta.com")
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/dashboard/widgets", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
for widget in data["items"]:
|
||||||
|
assert "plugin_name" in widget
|
||||||
|
assert "id" in widget
|
||||||
|
assert "component" in widget
|
||||||
|
assert "label_key" in widget
|
||||||
Reference in New Issue
Block a user