Files
tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
T
schalli 3f5afb0f54 feat(quick-260916-bwo): Dashboard-Raster verdoppelt (24 Spalten, 20 px, 8 px Abstand), Konstanten x2, einmalige Umrechnung gespeicherter Anordnungen mit Marker __gridVersion
- dashboard-grid.tsx: COLS 24/20/12/8/2, rowHeight 20, margin 8 (containerPadding folgt), Rueckfallwerte 4
- widget-registry.tsx: alle 32 Werte in WIDGET_CONSTRAINTS verdoppelt
- grid-layout-migration.ts (neu): migrateGridLayouts/withGridVersion, Marker nur im JSON, Idempotenz (T-BWO-02)
- dashboard-store.ts: Umrechnung beim Laden, Sofort-Speichern mit Marker, withGridVersion bei jedem saveLayout
- Tests: Migration 7 (neu), Store 6 (neu), Registry +1 (Tabelle), Grid +2 (Props ueber Mock), API-Spec +2 (Durchreichung __gridVersion, timeFontSizePt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 09:09:00 +02:00

123 lines
4.3 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { Responsive, noCompactor } 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';
import { TesseraLogo } from '@/components/brand/tessera-logo';
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
// in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig
// umgerechnet und mit `__gridVersion: 2` markiert. BREAKPOINTS unveraendert.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
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">
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols.
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem
Seitenhintergrund, nicht auf einer eigenen Flaeche. */}
<TesseraLogo variant="mark" size={64} plateOutline={false} />
<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={20}
margin={[8, 8] as [number, number]}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
enabled: isEditMode,
handle: '.widget-drag-handle',
}}
resizeConfig={{
enabled: isEditMode,
}}
compactor={noCompactor}
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 ?? 4,
h: constraints?.defaultH ?? 4,
}),
minW: constraints?.minW ?? 4,
minH: constraints?.minH ?? 4,
}}
>
<WidgetWrapper
widget={widget}
isEditMode={isEditMode}
onRemove={onRemoveWidget}
/>
</div>
);
})}
</Responsive>
</div>
);
}