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
+70
View File
@@ -0,0 +1,70 @@
/**
* Dashboard API client functions.
* All calls use credentials: 'include' for cookie-based auth.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export async function fetchLayout(): Promise<Record<string, unknown>> {
const res = await fetch(`${API_URL}/dashboard/layout`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch layout');
return res.json();
}
export async function saveLayout(
layouts: Record<string, unknown>,
): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ layouts }),
});
if (!res.ok) throw new Error('Failed to save layout');
}
export async function fetchWidgets(): Promise<
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
> {
const res = await fetch(`${API_URL}/dashboard/widgets`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch widgets');
return res.json();
}
export async function addWidget(
widgetType: string,
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
const res = await fetch(`${API_URL}/dashboard/widgets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ widgetType }),
});
if (!res.ok) throw new Error('Failed to add widget');
return res.json();
}
export async function removeWidget(id: string): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/widgets/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to remove widget');
}
export async function updateWidgetConfig(
id: string,
config: Record<string, unknown>,
): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/widgets/${id}/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ config }),
});
if (!res.ok) throw new Error('Failed to update widget config');
}
+136
View File
@@ -0,0 +1,136 @@
import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
export interface WidgetInstance {
id: string;
widgetType: string;
config: Record<string, unknown>;
}
interface DashboardState {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: WidgetInstance[];
isEditMode: boolean;
isDirty: boolean;
isLoading: boolean;
error: string | null;
setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void;
addWidget: (widgetType: WidgetType) => Promise<void>;
removeWidget: (id: string) => Promise<void>;
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
}
/**
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
* Save only on exiting edit mode (D-01), not on every drag.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [],
isEditMode: false,
isDirty: false,
isLoading: false,
error: null,
setEditMode: (mode: boolean) => {
const prev = get().isEditMode;
set({ isEditMode: mode });
// On exit edit mode, auto-save if dirty (D-01)
if (prev && !mode && get().isDirty) {
get().saveLayout();
}
},
updateLayouts: (layouts) => {
set({
layouts: layouts as DashboardState['layouts'],
isDirty: true,
});
},
addWidget: async (widgetType: WidgetType) => {
try {
const newWidget = await api.addWidget(widgetType);
const constraints = WIDGET_CONSTRAINTS[widgetType];
set((state) => {
// Add default layout entry for all breakpoints
const newLayouts = { ...state.layouts };
for (const bp of Object.keys(newLayouts)) {
const arr = [...(newLayouts[bp] || [])];
// Place at next available y position
const maxY = arr.reduce((max, item) => Math.max(max, item.y + item.h), 0);
arr.push({
i: newWidget.id,
x: 0,
y: maxY,
w: constraints.defaultW,
h: constraints.defaultH,
});
newLayouts[bp] = arr;
}
return {
widgets: [...state.widgets, newWidget],
layouts: newLayouts,
isDirty: true,
};
});
} catch {
set({ error: 'Failed to add widget' });
}
},
removeWidget: async (id: string) => {
try {
await api.removeWidget(id);
set((state) => {
const newLayouts = { ...state.layouts };
for (const bp of Object.keys(newLayouts)) {
newLayouts[bp] = newLayouts[bp].filter((item) => item.i !== id);
}
return {
widgets: state.widgets.filter((w) => w.id !== id),
layouts: newLayouts,
isDirty: true,
};
});
} catch {
set({ error: 'Failed to remove widget' });
}
},
loadDashboard: async () => {
set({ isLoading: true, error: null });
try {
const [layouts, widgets] = await Promise.all([
api.fetchLayout(),
api.fetchWidgets(),
]);
set({
layouts: layouts as DashboardState['layouts'],
widgets,
isLoading: false,
});
} catch {
set({
error: 'Failed to load dashboard',
isLoading: false,
});
}
},
saveLayout: async () => {
try {
await api.saveLayout(get().layouts);
set({ isDirty: false });
} catch {
set({ error: 'Failed to save layout' });
}
},
}));