'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Responsive, noCompactor } from 'react-grid-layout'; import type { Compactor, 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 }; // quick-260916-dyv: Ziehen zuverlaessig. // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). // - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links, // [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag` // (Favoriten-Widget). react-draggable 4.7.0 prueft // `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element // (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch // INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran // (chunk-WGL5FSZH.mjs:526), der Groessen-Griff funktioniert weiter. // - `threshold: 3` = RGL-Standard (Klick vs. Ziehen). export const WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle'; export const WIDGET_DRAG_CANCEL_SELECTOR = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; // quick-260916-dyv: Kein Ueberlappen beim Ablegen, freie Platzierung bleibt. // `noCompactor` (Commit c8f3361, "prevent auto-compaction on drag") ist gewollt: // Luecken werden nicht geschlossen. OHNE `preventCollision` springt beim Ziehen // auf ein belegtes Feld das gezogene Widget auf die Zeile des getroffenen und // das getroffene rutscht um seine EIGENE Hoehe nach unten — ohne Kaskade und // ohne Aufloesung, Ueberlappungen bleiben, weil `noCompactor.compact` die // Identitaet ist (gemessen chunk-76RTO6EO.mjs:279-328); beim Vergroessern in // einen Nachbarn entsteht die Ueberlappung stumm (chunk-WGL5FSZH.mjs:872-885). // MIT `preventCollision: true` bleibt das gezogene Widget am Ausgangsort // (`l.x = oldX; l.y = oldY`), und Vergroessern stoppt am Nachbarn. // `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666). export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true }; interface LayoutItemShape { i: string; x: number; y: number; w: number; h: number; minW?: number; minH?: number; } interface DashboardGridProps { layouts: Record>; widgets: Array<{ id: string; widgetType: string; config: Record }>; isEditMode: boolean; onLayoutChange: (allLayouts: ResponsiveLayouts) => void; onRemoveWidget: (id: string) => void; } /** * quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben. * * Warum: react-grid-layout 2.2.3 nimmt in `synchronizeLayoutWithChildren` * (chunk-WGL5FSZH.mjs:559-562) fuer jedes Kind MIT Eintrag im `layout` den * Eintrag per `cloneLayoutItem` woertlich — inklusive minW/minH — und liest * `data-grid` nur fuer Kinder OHNE Eintrag. Gespeicherte Anordnungen tragen * minW/minH (RGL liefert sie in `onLayoutChange`, 260916-bwo hat sie verdoppelt * persistiert). Eine Aenderung von WIDGET_CONSTRAINTS allein aendert fuer * bestehende Widgets also NICHTS. Deshalb: in JEDEM Breakpoint minW/minH jedes * Eintrags eines bekannten Typs aus der Tabelle setzen; die Konstanten sind * die einzige Quelle (T-DYV-01: manipulierte Minima wirken nicht). * * Liegt die gespeicherte Groesse UNTER dem neuen Minimum (z. B. Rechner mit * h 8, neues minH 10), wird w/h auf das Minimum angehoben: RGL selbst klemmt * w/h nur beim Vergroessern/Verkleinern (`minMaxSize.constrainSize`, * chunk-KDANGDDL.mjs:26-31; `minConstraints` an `Resizable`, * chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich — * der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten. * Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert. */ function applyConstraintMinima( layouts: Record>, widgets: Array<{ id: string; widgetType: string }>, ): Record> { const typeById = new Map(); for (const w of widgets) typeById.set(w.id, w.widgetType); const result: Record> = {}; for (const key of Object.keys(layouts)) { const entries = layouts[key]; if (!Array.isArray(entries)) continue; result[key] = entries.map((entry) => { const type = typeById.get(entry.i); const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined; if (!constraints) return { ...entry }; return { ...entry, w: Math.max(entry.w, constraints.minW), h: Math.max(entry.h, constraints.minH), minW: constraints.minW, minH: constraints.minH, }; }); } return result; } /** * 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 [width, setWidth] = useState(1200); // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf), // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste. // // Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene //
gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der // alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim // Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht, // wenn spaeter die erste Kachel erscheint und das
tatsaechlich // entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert // 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als // (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt // 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts. // // Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen // hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort // ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein // zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200 // lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau // diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute // schon in Produktion und er ist nachweislich richtig (459 px in 1176 px // gemessen) — ein anderer Startwert wuerde eine bisher unerprobte // Breakpoint-Folge einfuehren, ohne etwas zu verbessern. // // Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der // ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0 // und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig // zusammengefallene Flaeche das Raster nicht auf Null setzt und keine // Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst. const nodeRef = useRef(null); const observerRef = useRef(null); const applyWidth = useCallback((next: number) => { if (Number.isFinite(next) && next > 0) { setWidth(next); } }, []); const measureRef = useCallback( (node: HTMLDivElement | null) => { // Ein eventuell laufender Beobachter zuerst trennen — auch der // null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der // Aufraeumpfad (T-VDK-02). observerRef.current?.disconnect(); observerRef.current = null; nodeRef.current = node; if (!node) return; // Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen. applyWidth(node.getBoundingClientRect().width); const observer = new ResizeObserver((entries) => { applyWidth(entries[0].contentRect.width); }); observer.observe(node); observerRef.current = observer; // Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den // Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf. }, [applyWidth], ); useEffect(() => { const onResize = () => { if (nodeRef.current) { applyWidth(nodeRef.current.getBoundingClientRect().width); } }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [applyWidth]); // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // stabil bleibt. const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); // Empty state (D-02) if (widgets.length === 0) { return (
{/* 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. */}

{t('emptyHeading')}

{t('emptyBody')}

); } return (
onLayoutChange(allLayouts)} > {widgets.map((widget) => { const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; return (
l.i === widget.id) ?? { x: 0, y: 0, w: constraints?.defaultW ?? 4, h: constraints?.defaultH ?? 4, }), minW: constraints?.minW ?? 4, minH: constraints?.minH ?? 4, }} >
); })}
); }