diff --git a/app/main.py b/app/main.py index 9a874bd..3f37bf2 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from app.routes import ( auth, contact_folders, contacts, + dashboard, entity_history, groups, health, @@ -293,6 +294,7 @@ def create_app() -> FastAPI: app.include_router(notifications.router) app.include_router(contacts.router) app.include_router(contact_folders.router) + app.include_router(dashboard.router) app.include_router(entity_history.router) app.include_router(import_export.router) app.include_router(plugins.router) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index e04f6ca..d97b8ac 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -6,6 +6,7 @@ from app.routes import ( audit, # noqa: F401 auth, # noqa: F401 contacts, # noqa: F401 + dashboard, # noqa: F401 entity_history, # noqa: F401 currencies, # noqa: F401 taxes, # noqa: F401 diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..4d11a83 --- /dev/null +++ b/app/routes/dashboard.py @@ -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)} diff --git a/frontend/src/__tests__/Dashboard.test.tsx b/frontend/src/__tests__/Dashboard.test.tsx new file mode 100644 index 0000000..1a784f7 --- /dev/null +++ b/frontend/src/__tests__/Dashboard.test.tsx @@ -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 }) => ( +
Widget: {widget.label}
+ ), +})); + +// Mock Skeleton +vi.mock('@/components/ui/Skeleton', () => ({ + Skeleton: ({ className }: { className?: string }) => ( +
Loading...
+ ), +})); + +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(); + 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(); + expect(screen.getByTestId('dashboard-grid-empty')).toBeInTheDocument(); + }); + + it('renders widget labels', () => { + render(); + expect(screen.getByText('Recent Contacts')).toBeInTheDocument(); + expect(screen.getByText('Tasks Summary')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..3e13010 --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -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('/dashboard/widgets'), + staleTime: 60 * 1000, + }); +} diff --git a/frontend/src/components/contacts/DedupDialog.tsx b/frontend/src/components/contacts/DedupDialog.tsx index ab0c108..3a0902f 100644 --- a/frontend/src/components/contacts/DedupDialog.tsx +++ b/frontend/src/components/contacts/DedupDialog.tsx @@ -40,9 +40,9 @@ export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => v const overrides: Record = {}; for (const [field, value] of Object.entries(fieldOverrides)) { if (value === 'source') { - overrides[field] = (pair.source_contact as Record)[field]; + overrides[field] = (pair.source_contact as unknown as Record)[field]; } else if (value === 'target') { - overrides[field] = (pair.target_contact as Record)[field]; + overrides[field] = (pair.target_contact as unknown as Record)[field]; } } @@ -124,8 +124,8 @@ export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => v
{t('dedup.target')}
{compareFields.map((field) => { - const sourceVal = (pair.source_contact as Record)[field.key] as string | null; - const targetVal = (pair.target_contact as Record)[field.key] as string | null; + const sourceVal = (pair.source_contact as unknown as Record)[field.key] as string | null; + const targetVal = (pair.target_contact as unknown as Record)[field.key] as string | null; return (
{field.label}
diff --git a/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx b/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx new file mode 100644 index 0000000..d57f5ba --- /dev/null +++ b/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx @@ -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
{[...Array(2)].map((_, i) =>
)}
; + } + + if (isError) { + return

{t('dashboard.widgetError')}

; + } + + 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

{t('dashboard.noUpcomingEvents')}

; + } + + return ( +
+ {upcoming.map((entry: CalendarEntry) => ( +
+ +
+
{entry.title || '—'}
+
+ {formatDateTime(entry.start_at || entry.due_date) || ''} +
+ {entry.location && ( +
+ + {entry.location} +
+ )} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardGrid.tsx b/frontend/src/components/dashboard/DashboardGrid.tsx new file mode 100644 index 0000000..f4ac6d2 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardGrid.tsx @@ -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(null); + const [dragOverIndex, setDragOverIndex] = useState(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 ( +

+ {t('dashboard.noWidgets')} +

+ ); + } + + return ( +
+ {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 ( +
handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={() => handleDrop(index)} + onDragEnd={handleDragEnd} + data-testid={`dashboard-grid-item-${widget.id}`} + > +
+
+

+ {widget.label || t(widget.label_key)} +

+ ⋮⋮ +
+ +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardWidgetLoader.tsx b/frontend/src/components/dashboard/DashboardWidgetLoader.tsx new file mode 100644 index 0000000..63b90d1 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardWidgetLoader.tsx @@ -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> = { + '@/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 ( +
+

+ {t('dashboard.widgetNotAvailable', { name: widget.label || widget.id })} +

+
+ ); + } + + return ( +
+ }> + + +
+ ); +} diff --git a/frontend/src/components/dashboard/RecentContactsWidget.tsx b/frontend/src/components/dashboard/RecentContactsWidget.tsx new file mode 100644 index 0000000..8576ad3 --- /dev/null +++ b/frontend/src/components/dashboard/RecentContactsWidget.tsx @@ -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
{[...Array(3)].map((_, i) =>
)}
; + } + + if (isError) { + return

{t('dashboard.widgetError')}

; + } + + const contacts = data?.items ?? []; + + if (contacts.length === 0) { + return

{t('dashboard.noContacts')}

; + } + + return ( +
+ {contacts.map((contact) => ( +
+ + {contact.displayname} + {contact.email_1 && {contact.email_1}} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/dashboard/TasksSummaryWidget.tsx b/frontend/src/components/dashboard/TasksSummaryWidget.tsx new file mode 100644 index 0000000..6e92df1 --- /dev/null +++ b/frontend/src/components/dashboard/TasksSummaryWidget.tsx @@ -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
; + } + + if (isError) { + return

{t('dashboard.widgetError')}

; + } + + 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 ( +
+
+ + {openTasks.length} + {t('dashboard.openTasks')} +
+ {overdueTasks.length > 0 && ( +
+ + {overdueTasks.length} {t('dashboard.overdueTasks')} +
+ )} + {highPriority.length > 0 && ( +
+ + {highPriority.length} {t('dashboard.highPriorityTasks')} +
+ )} + {openTasks.length === 0 && ( +

{t('dashboard.noOpenTasks')}

+ )} +
+ ); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 55694d5..e4ba539 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -95,7 +95,19 @@ "statCompanies": "Firmen", "statContacts": "Kontakte", "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": { "title": "Firmen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 356a14e..28694b5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -95,7 +95,19 @@ "statCompanies": "Companies", "statContacts": "Contacts", "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": { "title": "Companies", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 144fcb3..f23465c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { StatCard } from '@/components/shared/StatCard'; import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed'; +import { DashboardGrid } from '@/components/dashboard/DashboardGrid'; import { useUnifiedContacts, useAuditLog } from '@/api/hooks'; +import { useDashboardWidgets } from '@/api/dashboard'; import { formatDateTime } from '@/utils/date'; export function DashboardPage() { @@ -10,6 +12,7 @@ export function DashboardPage() { const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company'); const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person'); const { data: auditData, isError: auditError } = useAuditLog(1, 5); + const { data: widgetsData, isError: widgetsError } = useDashboardWidgets(); const totalCompanies = companiesData?.total ?? 0; const totalContacts = contactsData?.total ?? 0; @@ -41,6 +44,8 @@ export function DashboardPage() { avatarUrl: null, })); + const widgets = widgetsData?.items ?? []; + return (

{t('dashboard.title')}

@@ -68,6 +73,18 @@ export function DashboardPage() { />
+ {/* Dynamic Plugin Widgets */} + {widgetsError ? ( +

+ {t('dashboard.widgetsUnavailable')} +

+ ) : widgets.length > 0 ? ( +
+

{t('dashboard.widgets')}

+ +
+ ) : null} + {auditError ? (

{t('dashboard.activityUnavailable')} diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..d6393e8 --- /dev/null +++ b/tests/test_dashboard.py @@ -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