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:
@@ -40,9 +40,9 @@ export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => v
|
||||
const overrides: Record<string, unknown> = {};
|
||||
for (const [field, value] of Object.entries(fieldOverrides)) {
|
||||
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') {
|
||||
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>
|
||||
{compareFields.map((field) => {
|
||||
const sourceVal = (pair.source_contact as Record<string, unknown>)[field.key] as string | null;
|
||||
const targetVal = (pair.target_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 unknown as Record<string, unknown>)[field.key] as string | null;
|
||||
return (
|
||||
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user