feat(05-01): dashboard grid, clock widget, widget registry, store, and tests

- Install react-grid-layout@2.2.3 and react-resizable
- Create widget-registry.tsx with all 4 widget types, WIDGET_CONSTRAINTS, WidgetProps
- Create dashboard-api.ts with fetch/save layout and widget CRUD functions
- Create dashboard-store.ts (Zustand, NO persist — D-05) with edit mode and auto-save on exit
- Create DashboardGrid with react-grid-layout v2 Responsive, ResizeObserver width
- Create ClockWidget using Intl.DateTimeFormat (no manual UTC offsets)
- Create WidgetWrapper with drag handle and delete button in edit mode
- Create EditModeToggle (pencil/checkmark), WidgetCatalogModal (2x2 grid)
- Rewrite portal page.tsx as dashboard with grid, edit toggle, widget catalog
- Add ResizeObserver polyfill in test setup, CSS mock support in vitest config
- All 5 tests green (dashboard grid + clock widget)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:21:43 +02:00
parent 792b7661d8
commit d5e1c42e64
14 changed files with 1115 additions and 29 deletions
@@ -0,0 +1,129 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Responsive } from 'react-grid-layout';
import type { ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
import { WidgetWrapper } from './widgets/widget-wrapper';
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 1 };
interface DashboardGridProps {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => void;
}
/**
* Dashboard grid wrapper using react-grid-layout v2 Responsive component.
* Renders widgets in a draggable/resizable grid when in edit mode.
* Shows empty state when no widgets exist.
*
* v2 API: uses dragConfig/resizeConfig instead of isDraggable/isResizable.
*/
export function DashboardGrid({
layouts,
widgets,
isEditMode,
onLayoutChange,
onRemoveWidget,
}: DashboardGridProps) {
const t = useTranslations('widgets');
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(1200);
// Measure container width (v2 requires explicit width — Pitfall 1)
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
setWidth(entries[0].contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Empty state (D-02)
if (widgets.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div className="rounded-lg bg-muted p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
</div>
<h2 className="text-lg font-semibold text-muted-foreground">
{t('emptyHeading')}
</h2>
<p className="text-sm text-muted-foreground">
{t('emptyBody')}
</p>
</div>
);
}
return (
<div ref={containerRef}>
<Responsive
width={width}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={layouts as ResponsiveLayouts}
rowHeight={40}
margin={[16, 16] as [number, number]}
dragConfig={{
enabled: isEditMode,
handle: '.widget-drag-handle',
}}
resizeConfig={{
enabled: isEditMode,
}}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return (
<div
key={widget.id}
data-grid={{
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 2,
h: constraints?.defaultH ?? 2,
}),
minW: constraints?.minW ?? 2,
minH: constraints?.minH ?? 2,
}}
>
<WidgetWrapper
widget={widget}
isEditMode={isEditMode}
onRemove={onRemoveWidget}
/>
</div>
);
})}
</Responsive>
</div>
);
}