diff --git a/PROGRESS.md b/PROGRESS.md index f870234..e1f279b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ **Kürzlich abgeschlossen:** Phase L vollständig (L1 Block-System, L2 Drag&Drop-Editor, L3 Renderer, L4 KI-Steuerung, L5 XRechnung-Format-Layer) — Details siehe Phase-L-Sections unten. **Offene Roadmap-Phasen (user-abgestimmt, startklar):** -- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓ erledigt** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS — siehe Phase-M2-Section). **Nächster Schritt: M3 Dashboard-Builder-Frontend** (Edit-Modus, Drag&Drop, Widget-Palette aus /api/v1/miniapps, Tabs, generisches Settings-Form). +- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓ erledigt** (Dashboard-Builder: Edit-Modus, @dnd-kit-Drag&Drop, Palette, Tabs, Settings-Forms — siehe Phase-M3-Section). **Nächster Schritt: M4 System-Rückbau** (StatCards→contacts-MiniApp, ActivityFeed→audit-MiniApp, SystemMetrics→System-MiniApp, Bestands-Widgets migrieren). - **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. - **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) @@ -83,7 +83,7 @@ **User-Vision:** Universelle MiniApps (Chat + Dashboard + Windows + AI-Agenten), Dashboard-Builder mit Edit-Modus/Drag&Drop/Resize/Tabs/pro-Widget-Settings, System-Dashboard-Teile zurück in Plugins (Core = reiner Host), Permission-Integration fail-closed. -**Status:** in_progress — M1 ✓, M2 ✓ erledigt (siehe Sections unten). Offen: M3 (Builder-Frontend), M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M. +**Status:** in_progress — M1 ✓, M2 ✓, M3 ✓ erledigt (siehe Sections unten). Offen: M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M. **Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden. @@ -102,7 +102,28 @@ - ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand - ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster) -**Offen in Phase M:** M3 Builder-Frontend (konsumiert /api/v1/dashboards + /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts. +**Offen in Phase M:** M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts. + +## Phase M3 — Dashboard-Builder-Frontend (2026-08-30) ✅ + +**Spec:** [#361](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/361) | **Roadmap:** Phase M, M3 + +**Umgesetzt:** +- `api/miniapps.ts` + `api/dashboards.ts`: TanStack-Query-Hooks (M2-Backend + M1-Registry, Query-Keys + Invalidation nach documents.ts-Muster); `renderableDashboardApps()` filtert Apps ohne component (6 der 9 Bestands-Apps sind Chat-Interaktions-Apps ohne Frontend-Component). +- `MiniAppHost.tsx`: ersetzt DashboardWidgetLoader (P2-F17-Erbe) — Lazy-Component-Registry + settings-Props an Widget-Komponenten; Apps ohne Component renderen render_schema-Karte (MiniAppBlock-Präzedenz). +- `DashboardBuilder.tsx` (605 Z.): View/Edit-Modus-Schalter; @dnd-kit-Sortable-Grid (rectSortingStrategy) mit 12-Spalten-Flow-Repositionierung (identisch zum Server-Seed); Palette (permission-gefiltert via /api/v1/miniapps?host=dashboard, nur renderbare Apps); Resize (col/row ±, geclamped 1-12); Tab-Verwaltung (Add/Remove/Rename, min 1); Dashboard-CRUD + Set-Default; Dirty-Check (Save disabled bei unverändertem Layout); Settings-Modal pro Widget. +- `WidgetSettingsForm.tsx`: generisches Form aus settings_schema (text/number/boolean/select, MiniAppField-Typ). +- Bestands-Widgets auf settings-Props umgestellt (RecentContacts nutzt settings.limit, geclamped 1-50; Tasks/Calendar kompatibel optional). +- `Dashboard.tsx`: Builder als Hauptinhalt (reiner Host-Pattern); StatCards/SystemMetrics/ActivityFeed bleiben sichtbar bis M4-Rückbau (kein Funktionsverlust). +- Legacy `DashboardGrid.tsx` + `DashboardWidgetLoader.tsx` gelöscht; Geister-Test ersetzt (§10: UI-Änderung → Test-Nachzug). +- i18n: dashboard.builder.* 22 Keys (de+en). + +**Verifiziert (2026-08-30):** +- ✅ Vitest: DashboardBuilder-Tests **13/13** (Render, Tabs, View/Edit-Schalter, Palette-Add, Save-Flow-Koordinaten-Beweis {col:3,row:0}, Dirty-Disabled, Resize→col_span 3, Tab-Add/Remove, Settings-Modal mit Schema-Feld, Dashboard-Wechsel, Create-Modal, Empty-State) — TDD-äquivalent: 2 anfängliche Test-Bugs (multiple elements) gefixt, dann grün +- ✅ Page-Regression: dashboard/Dashboard.test.tsx **11/11** (Builder-Mocks ergänzt); i18n-Test grün (de/en) +- ✅ `npx tsc --noEmit` clean; Production-Build **OK** (2.98s) +- ⚠️ Sidebar-Test-Run: Worker-OOM (0 Tests ausgeführt) = PROGRESS.md Finding #3 Vorbestand (identisch bei AppShell-Runs vor M3) +- Ausstehend: Frontend-Deploy + Produktions-Verifikation (wird nachgetragen) ## Phase M2 — Dashboard-Backend (2026-08-30) ✅ diff --git a/frontend/src/__tests__/Dashboard.test.tsx b/frontend/src/__tests__/Dashboard.test.tsx index 1a784f7..516d691 100644 --- a/frontend/src/__tests__/Dashboard.test.tsx +++ b/frontend/src/__tests__/Dashboard.test.tsx @@ -1,74 +1,240 @@ /** - * Dashboard tests — Task 5.25. + * DashboardBuilder tests (Phase M3). + * + * Replaces the old DashboardGrid tests (component deleted with M3 — the + * builder now owns the grid). Covers: rendering from server layout, + * edit mode with palette, add/remove widget, resize, tabs, dirty-state + * save, dashboard selection and set-default. */ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { DashboardGrid } from '@/components/dashboard/DashboardGrid'; -import type { DashboardWidgetDef } from '@/api/dashboard'; +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DashboardBuilder } from '@/components/dashboard/DashboardBuilder'; +import type { Dashboard } from '@/api/dashboards'; +import type { MiniAppDef } from '@/api/miniapps'; -// Mock i18n +// i18n mock vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ t: (key: string, opts?: Record) => + key.includes('{{name}}') && opts ? key.replace('{{name}}', opts.name) : key }), })); -// Mock DashboardWidgetLoader to avoid lazy loading in tests -vi.mock('@/components/dashboard/DashboardWidgetLoader', () => ({ - DashboardWidgetLoader: ({ widget }: { widget: DashboardWidgetDef }) => ( -
Widget: {widget.label}
+// ─── Shared mocks ─── +const mutateFns = { update: vi.fn(), create: vi.fn(), del: vi.fn(), setDefault: vi.fn() }; + +vi.mock('@/api/dashboards', async () => { + const actual = await vi.importActual>('@/api/dashboards'); + return { + ...actual, + useDashboards: () => ({ data: mockDashboards, isLoading: false }), + useCreateDashboard: () => ({ mutate: mutateFns.create, isPending: false }), + useUpdateDashboard: () => ({ mutate: mutateFns.update, isPending: false }), + useDeleteDashboard: () => ({ mutate: mutateFns.del }), + useSetDefaultDashboard: () => ({ mutate: mutateFns.setDefault }), + }; +}); + +vi.mock('@/api/miniapps', async () => { + const actual = await vi.importActual>('@/api/miniapps'); + return { + ...actual, + useMiniapps: () => ({ data: { items: mockMiniapps, total: mockMiniapps.length }, isLoading: false }), + }; +}); + +// MiniAppHost mock (no lazy loading in tests) +vi.mock('@/components/dashboard/MiniAppHost', () => ({ + MiniAppHost: ({ appId }: { appId: string }) => ( +
app:{appId}
), })); -// Mock Skeleton +// UI primitives vi.mock('@/components/ui/Skeleton', () => ({ Skeleton: ({ className }: { className?: string }) => (
Loading...
), })); -const mockWidgets: DashboardWidgetDef[] = [ +let mockDashboards: Dashboard[]; +const mockMiniapps: MiniAppDef[] = [ { - id: 'recent_contacts', - label_key: 'dashboard.recentContacts', - label: 'Recent Contacts', - component: '@/components/dashboard/RecentContactsWidget', - icon: 'Users', - order: 10, - col_span: 2, - row_span: 1, - permission: '', - plugin_name: 'contacts', + app_id: 'recent_contacts', name: 'Recent Contacts', icon: 'Users', description: '', + plugin_name: 'contacts', render_schema: {}, permission: '', + settings_schema: { fields: [{ name: 'limit', label: 'Limit', type: 'number', default: 5 }] }, + col_span: 2, row_span: 1, hosts: ['chat', 'dashboard', 'window'], + component: '@/components/dashboard/RecentContactsWidget', order: 10, builtin: true, }, { - id: 'tasks_summary', - label_key: 'dashboard.tasksSummary', - label: 'Tasks Summary', - component: '@/components/dashboard/TasksSummaryWidget', - icon: 'CheckSquare', - order: 20, - col_span: 1, - row_span: 1, - permission: '', - plugin_name: 'tasks', + app_id: 'tasks_summary', name: 'Tasks Summary', icon: 'CheckSquare', description: '', + plugin_name: 'tasks', render_schema: {}, permission: '', + settings_schema: {}, + col_span: 1, row_span: 1, hosts: ['chat', 'dashboard', 'window'], + component: '@/components/dashboard/TasksSummaryWidget', order: 20, builtin: true, }, ]; -describe('DashboardGrid', () => { - it('renders widgets in a grid', () => { - render(); - expect(screen.getByTestId('dashboard-grid')).toBeInTheDocument(); - expect(screen.getByTestId('dashboard-grid-item-recent_contacts')).toBeInTheDocument(); - expect(screen.getByTestId('dashboard-grid-item-tasks_summary')).toBeInTheDocument(); +function makeDashboard(overrides?: Partial): Dashboard { + return { + id: 'd-1', + name: 'Mein Dashboard', + is_default: true, + user_id: 'u-1', + created_at: '2026-08-30T10:00:00Z', + updated_at: '2026-08-30T10:00:00Z', + layout: { + version: 1, + tabs: [ + { id: 'start', name: 'Start', widgets: [ + { app_id: 'recent_contacts', settings: { limit: 5 }, col: 0, row: 0, col_span: 2, row_span: 1 }, + { app_id: 'tasks_summary', settings: {}, col: 2, row: 0, col_span: 1, row_span: 1 }, + ] }, + ], + }, + ...overrides, + }; +} + +function renderBuilder() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDashboards = [makeDashboard()]; +}); + +// ─── Tests ─── + +describe('DashboardBuilder', () => { + it('renders the builder with widgets from the server layout', () => { + renderBuilder(); + expect(screen.getByTestId('dashboard-builder')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-widget-recent_contacts')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-widget-tasks_summary')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-recent_contacts')).toBeInTheDocument(); }); - it('shows empty message when no widgets', () => { - render(); + it('renders tab bar with tab names', () => { + renderBuilder(); + expect(screen.getByTestId('dashboard-tabs')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-tab-0')).toHaveTextContent('Start'); + }); + + it('hides edit controls in view mode', () => { + renderBuilder(); + expect(screen.getByTestId('dashboard-edit')).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-palette')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-widget-remove')).not.toBeInTheDocument(); + }); + + it('shows palette and widget controls in edit mode', () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + expect(screen.getByTestId('dashboard-palette')).toBeInTheDocument(); + expect(screen.getAllByTestId('dashboard-widget-remove').length).toBe(2); + expect(screen.getAllByTestId('dashboard-widget-settings').length).toBe(2); + }); + + it('adds a widget from the palette in edit mode', () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + // remove both existing widgets, then add one back via palette + fireEvent.click(screen.getAllByTestId('dashboard-widget-remove')[0]); + fireEvent.click(screen.getAllByTestId('dashboard-widget-remove')[0]); + expect(screen.getByTestId('dashboard-grid-empty')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('dashboard-add-tasks_summary')); + expect(screen.getByTestId('dashboard-widget-tasks_summary')).toBeInTheDocument(); + }); + + it('calls update with the changed layout on save', async () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + fireEvent.click(screen.getByTestId('dashboard-add-tasks_summary')); + fireEvent.click(screen.getByTestId('dashboard-save')); + await waitFor(() => expect(mutateFns.update).toHaveBeenCalledTimes(1)); + const call = mutateFns.update.mock.calls[0][0]; + expect(call.id).toBe('d-1'); + const widgets = call.input.layout.tabs[0].widgets; + expect(widgets).toHaveLength(3); + // flow: 2+1+1 = 4 columns, all on row 0 + expect(widgets[2]).toMatchObject({ app_id: 'tasks_summary', col: 3, row: 0, col_span: 1 }); + }); + + it('does not call update when nothing changed (save disabled)', async () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + expect(screen.getByTestId('dashboard-save')).toBeDisabled(); + }); + + it('resizes a widget wider', () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + const controls = screen.getAllByTestId('dashboard-widget-controls')[0]; + const widerBtn = controls.querySelectorAll('button')[1]; // [narrower, wider, shorter, taller, settings, remove] + fireEvent.click(widerBtn); + fireEvent.click(screen.getByTestId('dashboard-save')); + expect(mutateFns.update).toHaveBeenCalled(); + const widgets = mutateFns.update.mock.calls[0][0].input.layout.tabs[0].widgets; + expect(widgets[0]).toMatchObject({ app_id: 'recent_contacts', col_span: 3 }); + }); + + it('adds and removes tabs in edit mode', () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + fireEvent.click(screen.getByTestId('dashboard-add-tab')); + expect(screen.getByTestId('dashboard-tab-1')).toBeInTheDocument(); + // only one tab can be removed (min 1) + fireEvent.click(screen.getByTestId('dashboard-tab-1')); + const removeTabBtn = screen.getByTestId('dashboard-tabs').querySelectorAll('button[aria-label*="removeTab"], button[aria-label="dashboard.builder.removeTab"]'); + // remove second tab via its trash button + const trashButtons = screen.getByTestId('dashboard-tabs').querySelectorAll('button'); + fireEvent.click(trashButtons[3]); // tab0-remove? tab1-remove + expect(screen.queryByTestId('dashboard-tab-1')).not.toBeInTheDocument(); + }); + + it('opens widget settings modal with schema fields', () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-edit')); + fireEvent.click(screen.getAllByTestId('dashboard-widget-settings')[0]); + expect(screen.getByTestId('widget-settings-form')).toBeInTheDocument(); + expect(screen.getByTestId('widget-setting-limit')).toBeInTheDocument(); + }); + + it('selects between multiple dashboards', () => { + mockDashboards = [ + makeDashboard(), + makeDashboard({ id: 'd-2', name: 'Zweites', is_default: false, + layout: { version: 1, tabs: [{ id: 't', name: 'T', widgets: [] }] } }), + ]; + renderBuilder(); + const select = screen.getByTestId('dashboard-select') as HTMLSelectElement; + expect(select.value).toBe('d-1'); + expect(screen.getByText('Zweites')).toBeInTheDocument(); + fireEvent.change(select, { target: { value: 'd-2' } }); expect(screen.getByTestId('dashboard-grid-empty')).toBeInTheDocument(); }); - it('renders widget labels', () => { - render(); - expect(screen.getByText('Recent Contacts')).toBeInTheDocument(); - expect(screen.getByText('Tasks Summary')).toBeInTheDocument(); + it('creates a new dashboard via modal', async () => { + renderBuilder(); + fireEvent.click(screen.getByTestId('dashboard-create')); + fireEvent.change(screen.getByTestId('dashboard-create-name'), { target: { value: 'Vertrieb' } }); + fireEvent.click(screen.getByTestId('dashboard-create-submit')); + await waitFor(() => expect(mutateFns.create).toHaveBeenCalledWith({ name: 'Vertrieb' })); + }); + + it('shows empty state when no dashboards exist', () => { + mockDashboards = []; + const { rerender } = renderBuilder(); + // useDashboards returns empty -> builder shows unavailable (lazy seed comes from server) + expect(screen.getByTestId('dashboard-builder-empty')).toBeInTheDocument(); + void rerender; }); }); diff --git a/frontend/src/__tests__/dashboard/Dashboard.test.tsx b/frontend/src/__tests__/dashboard/Dashboard.test.tsx index 14954da..ef8ba77 100644 --- a/frontend/src/__tests__/dashboard/Dashboard.test.tsx +++ b/frontend/src/__tests__/dashboard/Dashboard.test.tsx @@ -30,6 +30,26 @@ vi.mock('@/api/dashboard', () => ({ }), })); +// M3: DashboardPage renders the DashboardBuilder — mock its data layer +vi.mock('@/api/dashboards', () => ({ + useDashboards: () => ({ data: [], isLoading: false }), + useCreateDashboard: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateDashboard: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteDashboard: () => ({ mutate: vi.fn() }), + useSetDefaultDashboard: () => ({ mutate: vi.fn() }), +})); + +vi.mock('@/api/miniapps', () => ({ + useMiniapps: () => ({ data: { items: [], total: 0 } }), + renderableDashboardApps: (apps: unknown[]) => (apps as never[]), +})); + +vi.mock('@/components/dashboard/MiniAppHost', () => ({ + MiniAppHost: ({ appId }: { appId: string }) => ( +
app:{appId}
+ ), +})); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); diff --git a/frontend/src/api/dashboards.ts b/frontend/src/api/dashboards.ts new file mode 100644 index 0000000..76933e4 --- /dev/null +++ b/frontend/src/api/dashboards.ts @@ -0,0 +1,97 @@ +/** + * Personal dashboards API hooks (Phase M2/M3). + * + * Layout shape (validated server-side, DashboardLayout): + * { version: 1, tabs: [{ id, name, widgets: [{ app_id, settings, col, row, col_span, row_span }] }] } + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPut, apiDelete } from './client'; + +export interface DashboardWidget { + app_id: string; + settings: Record; + col: number; + row: number; + col_span: number; + row_span: number; +} + +export interface DashboardTab { + id: string; + name: string; + widgets: DashboardWidget[]; +} + +export interface DashboardLayout { + version: number; + tabs: DashboardTab[]; +} + +export interface Dashboard { + id: string; + name: string; + layout: DashboardLayout; + is_default: boolean; + user_id: string; + created_at: string | null; + updated_at: string | null; +} + +export interface DashboardInput { + name: string; +} + +export interface DashboardUpdateInput { + name?: string; + layout?: DashboardLayout; +} + +export const DASHBOARD_QUERY_KEYS = { + all: ['dashboards'] as const, + one: (id: string) => ['dashboards', id] as const, +}; + +export function useDashboards() { + return useQuery({ + queryKey: DASHBOARD_QUERY_KEYS.all, + queryFn: () => apiGet('/dashboards'), + staleTime: 30 * 1000, + }); +} + +export function useCreateDashboard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: DashboardInput) => apiPost('/dashboards', input), + onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }), + }); +} + +export function useUpdateDashboard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: DashboardUpdateInput }) => + apiPut(`/dashboards/${id}`, input), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }); + qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.one(vars.id) }); + }, + }); +} + +export function useDeleteDashboard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/dashboards/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }), + }); +} + +export function useSetDefaultDashboard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiPost(`/dashboards/${id}/set-default`), + onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }), + }); +} diff --git a/frontend/src/api/miniapps.ts b/frontend/src/api/miniapps.ts new file mode 100644 index 0000000..54e2518 --- /dev/null +++ b/frontend/src/api/miniapps.ts @@ -0,0 +1,60 @@ +/** + * MiniApps API hooks (Phase M1/M3). + * + * The universal MiniApp registry listing is permission-filtered + * server-side; hosts additionally filter by ?host= and by whether a + * frontend component is available (chat-only interaction apps have no + * component path and cannot be placed on a dashboard). + */ + +import { useQuery } from '@tanstack/react-query'; +import { apiGet } from './client'; + +export interface MiniAppField { + name: string; + label?: string; + type?: string; // text | number | boolean | select + default?: unknown; + options?: { value: string; label: string }[]; +} + +export interface MiniAppDef { + app_id: string; + name: string; + icon: string; + description: string; + plugin_name: string; + render_schema: Record; + permission: string; + settings_schema: { fields?: MiniAppField[] } & Record; + col_span: number; + row_span: number; + hosts: string[]; + component: string; + order: number; + builtin: boolean; +} + +export interface MiniAppsResponse { + items: MiniAppDef[]; + total: number; +} + +export const MINIAPP_QUERY_KEYS = { + all: ['miniapps'] as const, + host: (host: string) => ['miniapps', host] as const, +}; + +export function useMiniapps(host?: string) { + return useQuery({ + queryKey: MINIAPP_QUERY_KEYS.host(host ?? 'all'), + queryFn: () => + apiGet(host ? `/miniapps?host=${encodeURIComponent(host)}` : '/miniapps'), + staleTime: 60 * 1000, + }); +} + +/** Apps that can actually be rendered on a dashboard (component present). */ +export function renderableDashboardApps(apps: MiniAppDef[] | undefined): MiniAppDef[] { + return (apps ?? []).filter((a) => Boolean(a.component)); +} diff --git a/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx b/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx index d57f5ba..c4d5bb3 100644 --- a/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx +++ b/frontend/src/components/dashboard/CalendarUpcomingWidget.tsx @@ -5,11 +5,12 @@ import React from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost'; import { listEntries, type CalendarEntry } from '@/api/calendar'; import { formatDateTime } from '@/utils/date'; import { Calendar, MapPin } from 'lucide-react'; -export function CalendarUpcomingWidget() { +export function CalendarUpcomingWidget({ settings }: WidgetComponentProps) { const { t } = useTranslation(); const { data, isLoading, isError } = useQuery({ queryKey: ['calendarEntries', 'upcoming'], diff --git a/frontend/src/components/dashboard/DashboardBuilder.tsx b/frontend/src/components/dashboard/DashboardBuilder.tsx new file mode 100644 index 0000000..f9d4e71 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardBuilder.tsx @@ -0,0 +1,605 @@ +/** + * DashboardBuilder — personal dashboard host + builder (Phase M3). + * + * View mode: renders the active tab's widgets in a 12-column flow grid. + * Edit mode: @dnd-kit sortable grid (reorder with flow re-positioning, + * same flow as the server-side seed), widget palette (MiniApps with a + * frontend component), resize (col/row span), per-instance settings from + * settings_schema, tab management, dashboard CRUD + set-default. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + LayoutDashboard, + Pencil, + Check, + Plus, + Trash2, + GripVertical, + Settings2, + Star, + StarOff, + Minus, +} from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { + useDashboards, + useCreateDashboard, + useUpdateDashboard, + useDeleteDashboard, + useSetDefaultDashboard, + type Dashboard, + type DashboardTab, + type DashboardWidget, +} from '@/api/dashboards'; +import { useMiniapps, renderableDashboardApps, type MiniAppDef } from '@/api/miniapps'; +import { MiniAppHost } from './MiniAppHost'; +import { WidgetSettingsForm } from './WidgetSettingsForm'; + +let widgetIdCounter = 0; +function nextTabId(): string { + widgetIdCounter += 1; + return `tab_${Date.now().toString(36)}_${widgetIdCounter}`; +} + +/** Assign col/row from a flow layout over 12 columns (seed-consistent). */ +function reflow(widgets: DashboardWidget[]): DashboardWidget[] { + let col = 0; + let row = 0; + return widgets.map((w) => { + const span = Math.max(1, Math.min(12, w.col_span || 1)); + if (col + span > 12) { + row += 1; + col = 0; + } + const placed = { ...w, col_span: span, col, row }; + col += span; + return placed; + }); +} + +function normalizeTab(tab: DashboardTab): DashboardTab { + return { ...tab, widgets: reflow(tab.widgets) }; +} + +interface SortableWidgetProps { + widget: DashboardWidget; + def: MiniAppDef | undefined; + editMode: boolean; + onRemove: () => void; + onResize: (dCol: number, dRow: number) => void; + onOpenSettings: () => void; +} + +function SortableWidget({ widget, def, editMode, onRemove, onResize, onOpenSettings }: SortableWidgetProps) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: `${widget.col}-${widget.row}-${widget.app_id}-${widget.col_span}`, + disabled: !editMode, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + gridColumn: `span ${Math.min(12, Math.max(1, widget.col_span))} / span ${Math.min(12, Math.max(1, widget.col_span))}`, + gridRow: `span ${Math.max(1, widget.row_span)} / span ${Math.max(1, widget.row_span)}`, + }; + + return ( +
+
+

+ {editMode && ( + + )} + {def?.name ?? widget.app_id} +

+ {editMode && ( +
+ + + + + + +
+ )} +
+ +
+ ); +} + +export function DashboardBuilder() { + const { t } = useTranslation(); + const { data: dashboards, isLoading } = useDashboards(); + const { data: miniappsData } = useMiniapps('dashboard'); + const createDashboard = useCreateDashboard(); + const updateDashboard = useUpdateDashboard(); + const deleteDashboard = useDeleteDashboard(); + const setDefaultDashboard = useSetDefaultDashboard(); + + const appsById = useMemo(() => { + const map = new Map(); + for (const a of miniappsData?.items ?? []) map.set(a.app_id, a); + return map; + }, [miniappsData]); + + const [activeId, setActiveId] = useState(null); + const [activeTabIndex, setActiveTabIndex] = useState(0); + const [editMode, setEditMode] = useState(false); + const [layout, setLayout] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [deleteOpen, setDeleteOpen] = useState(false); + const [settingsWidget, setSettingsWidget] = useState(null); + const [renameTab, setRenameTab] = useState(null); + + const active = useMemo( + () => dashboards?.find((d) => d.id === activeId) ?? dashboards?.find((d) => d.is_default) ?? dashboards?.[0], + [dashboards, activeId] + ); + + useEffect(() => { + if (active && (!layout || (editMode === false && layout))) { + // Sync from server when not editing (or first load) + setLayout({ ...active.layout, tabs: active.layout.tabs.map(normalizeTab) }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active?.id, active?.updated_at]); + + useEffect(() => { + setActiveTabIndex((i) => Math.min(i, Math.max(0, (layout?.tabs.length ?? 1) - 1))); + }, [layout?.tabs.length]); + + const tabs = layout?.tabs ?? []; + const activeTab = tabs[Math.min(activeTabIndex, Math.max(0, tabs.length - 1))]; + const dirty = + !!active && !!layout && JSON.stringify(normalizeLayout(layout)) !== JSON.stringify(normalizeLayout(active.layout)); + + function normalizeLayout(l: Dashboard['layout']): Dashboard['layout'] { + return { version: l.version ?? 1, tabs: l.tabs.map(normalizeTab) }; + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + function mutateTab(fn: (tab: DashboardTab) => DashboardTab) { + setLayout((prev) => { + if (!prev) return prev; + const nextTabs = prev.tabs.map((tab, i) => (i === activeTabIndex ? fn(tab) : tab)); + return { ...prev, tabs: nextTabs }; + }); + } + + function handleDragEnd(event: DragEndEvent) { + const { active: a, over } = event; + if (!over || a.id === over.id || !activeTab) return; + const ids = activeTab.widgets.map((w) => sortableIdFor(w)); + const from = ids.indexOf(String(a.id)); + const to = ids.indexOf(String(over.id)); + if (from < 0 || to < 0 || from === to) return; + mutateTab((tab) => { + const next = arrayMove(tab.widgets, from, to); + return { ...tab, widgets: reflow(next) }; + }); + } + + function sortableIdFor(w: DashboardWidget): string { + return `${w.col}-${w.row}-${w.app_id}-${w.col_span}`; + } + + function addWidget(app: MiniAppDef) { + mutateTab((tab) => + normalizeTab({ + ...tab, + widgets: [...tab.widgets, { + app_id: app.app_id, + settings: {}, + col: 0, + row: 0, + col_span: Math.min(4, Math.max(1, app.col_span || 1)), + row_span: Math.max(1, app.row_span || 1), + }], + }) + ); + } + + function removeWidget(target: DashboardWidget) { + mutateTab((tab) => normalizeTab({ ...tab, widgets: tab.widgets.filter((w) => w !== target) })); + } + + function resizeWidget(target: DashboardWidget, dCol: number, dRow: number) { + mutateTab((tab) => + normalizeTab({ + ...tab, + widgets: tab.widgets.map((w) => + w === target + ? { + ...w, + col_span: Math.max(1, Math.min(12, (w.col_span || 1) + dCol)), + row_span: Math.max(1, Math.min(12, (w.row_span || 1) + dRow)), + } + : w + ), + }) + ); + } + + function saveLayout() { + if (!active || !layout) return; + updateDashboard.mutate({ id: active.id, input: { layout: normalizeLayout(layout) } }); + setEditMode(false); + } + + function addTab() { + const id = nextTabId(); + setLayout((prev) => + prev ? { ...prev, tabs: [...prev.tabs, { id, name: t('dashboard.builder.newTab'), widgets: [] }] } : prev + ); + setActiveTabIndex((layout?.tabs.length ?? 0)); + } + + function removeTab(index: number) { + setLayout((prev) => { + if (!prev || prev.tabs.length <= 1) return prev; + return { ...prev, tabs: prev.tabs.filter((_, i) => i !== index) }; + }); + setActiveTabIndex(0); + } + + if (isLoading) { + return ; + } + + if (!active) { + return ( +

+ {t('dashboard.widgetsUnavailable')} +

+ ); + } + + const palette = renderableDashboardApps(miniappsData?.items); + + return ( +
+ {/* Dashboard header */} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardGrid.tsx b/frontend/src/components/dashboard/DashboardGrid.tsx deleted file mode 100644 index f4ac6d2..0000000 --- a/frontend/src/components/dashboard/DashboardGrid.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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(null); - const [dragOverIndex, setDragOverIndex] = useState(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 ( -

- {t('dashboard.noWidgets')} -

- ); - } - - return ( -
- {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 ( -
handleDragStart(index)} - onDragOver={(e) => handleDragOver(e, index)} - onDrop={() => handleDrop(index)} - onDragEnd={handleDragEnd} - data-testid={`dashboard-grid-item-${widget.id}`} - > -
-
-

- {widget.label || t(widget.label_key)} -

- ⋮⋮ -
- -
-
- ); - })} -
- ); -} diff --git a/frontend/src/components/dashboard/DashboardWidgetLoader.tsx b/frontend/src/components/dashboard/DashboardWidgetLoader.tsx deleted file mode 100644 index e77f3f4..0000000 --- a/frontend/src/components/dashboard/DashboardWidgetLoader.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// TODO: P2-F17 — Replace hardcoded widgetRegistry with dynamic manifest config -/** - * 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> = { - '@/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 ( -
-

- {t('dashboard.widgetNotAvailable', { name: widget.label || widget.id })} -

-
- ); - } - - return ( -
- }> - - -
- ); -} diff --git a/frontend/src/components/dashboard/MiniAppHost.tsx b/frontend/src/components/dashboard/MiniAppHost.tsx new file mode 100644 index 0000000..8994584 --- /dev/null +++ b/frontend/src/components/dashboard/MiniAppHost.tsx @@ -0,0 +1,91 @@ +/** + * MiniAppHost — renders a MiniApp instance on a host (Phase M3). + * + * Resolves the frontend component via the MiniApp definition's component + * path (lazy registry, P2-F17 successor). Apps without a component (chat + * interaction apps) render a compact render_schema card (MiniAppBlock + * precedent). Widget components receive their per-instance settings. + */ + +import React, { lazy, Suspense } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppWindow } from 'lucide-react'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { useMiniapps, type MiniAppDef } from '@/api/miniapps'; + +export interface WidgetComponentProps { + settings?: Record; +} + +const widgetRegistry: Record>> = { + '@/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 MiniAppHostProps { + appId: string; + settings: Record; + /** Pre-resolved definition (avoids duplicate fetches inside lists). */ + def?: MiniAppDef | undefined; +} + +function SchemaCard({ def, settings }: { def: MiniAppDef; settings: Record }) { + const fields = (def.render_schema as { fields?: { name: string; label?: string }[] }).fields ?? []; + const keys = fields.length > 0 ? fields.map((f) => f.name) : Object.keys(settings); + return ( +
+ {keys.length === 0 && ( +

{def.description || def.name}

+ )} + {keys.map((key) => ( +
+ {key} + + {settings[key] != null ? String(settings[key]) : '—'} + +
+ ))} +
+ ); +} + +export function MiniAppHost({ appId, settings, def }: MiniAppHostProps) { + const { t } = useTranslation(); + const { data, isLoading } = useMiniapps('dashboard'); + const resolved = def ?? data?.items.find((a) => a.app_id === appId); + + if (resolved && resolved.component) { + const WidgetComponent = widgetRegistry[resolved.component]; + if (WidgetComponent) { + return ( +
+ }> + + +
+ ); + } + } + + if (isLoading) { + return ; + } + + if (resolved) { + return ; + } + + return ( +
+
+ ); +} diff --git a/frontend/src/components/dashboard/RecentContactsWidget.tsx b/frontend/src/components/dashboard/RecentContactsWidget.tsx index 8576ad3..def699f 100644 --- a/frontend/src/components/dashboard/RecentContactsWidget.tsx +++ b/frontend/src/components/dashboard/RecentContactsWidget.tsx @@ -4,13 +4,15 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost'; import { useUnifiedContacts } from '@/api/hooks'; import { formatDateTime } from '@/utils/date'; import { Users } from 'lucide-react'; -export function RecentContactsWidget() { +export function RecentContactsWidget({ settings }: WidgetComponentProps) { const { t } = useTranslation(); - const { data, isLoading, isError } = useUnifiedContacts(1, 5, undefined, undefined); + const limit = Math.max(1, Math.min(50, Number(settings?.limit ?? 5) || 5)); + const { data, isLoading, isError } = useUnifiedContacts(1, limit, undefined, undefined); if (isLoading) { return
{[...Array(3)].map((_, i) =>
)}
; diff --git a/frontend/src/components/dashboard/TasksSummaryWidget.tsx b/frontend/src/components/dashboard/TasksSummaryWidget.tsx index 6e92df1..dce9fff 100644 --- a/frontend/src/components/dashboard/TasksSummaryWidget.tsx +++ b/frontend/src/components/dashboard/TasksSummaryWidget.tsx @@ -4,10 +4,11 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost'; import { useTasks } from '@/api/tasks'; import { CheckSquare, AlertCircle, Clock } from 'lucide-react'; -export function TasksSummaryWidget() { +export function TasksSummaryWidget({ settings }: WidgetComponentProps) { const { t } = useTranslation(); const { data, isLoading, isError } = useTasks(1, 100); diff --git a/frontend/src/components/dashboard/WidgetSettingsForm.tsx b/frontend/src/components/dashboard/WidgetSettingsForm.tsx new file mode 100644 index 0000000..adaacd8 --- /dev/null +++ b/frontend/src/components/dashboard/WidgetSettingsForm.tsx @@ -0,0 +1,86 @@ +/** + * WidgetSettingsForm — generic per-instance settings form (Phase M3). + * + * Renders fields from the MiniApp's settings_schema (same philosophy as + * the block config panels in the documents editor). Unknown schema + * shapes fall back to a key/value editor over existing settings. + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import type { MiniAppField } from '@/api/miniapps'; + +interface WidgetSettingsFormProps { + fields: MiniAppField[]; + values: Record; + onChange: (key: string, value: unknown) => void; +} + +export function WidgetSettingsForm({ fields, values, onChange }: WidgetSettingsFormProps) { + const { t } = useTranslation(); + + if (fields.length === 0) { + return ( +

+ {t('dashboard.builder.noSettings')} +

+ ); + } + + return ( +
+ {fields.map((field) => { + const key = field.name; + const raw = values[key] ?? field.default ?? ''; + const type = (field.type ?? 'text').toLowerCase(); + + if (type === 'boolean') { + const checked = raw === true || raw === 'true'; + return ( + + ); + } + + if (type === 'select' && field.options && field.options.length > 0) { + return ( +
+ + onChange(key, type === 'number' ? Number(e.target.value) : e.target.value) + } + data-testid={`widget-setting-${key}`} + /> +
+ ); + })} +
+ ); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 7253710..02efc21 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -138,6 +138,30 @@ "down": "Nicht verfügbar", "unknown": "Unbekannt" } + }, + "builder": { + "edit": "Bearbeiten", + "save": "Speichern", + "create": "Neues Dashboard", + "delete": "Dashboard löschen", + "deleteConfirm": "Dashboard \"{{name}}\" wirklich löschen?", + "setDefault": "Als Standard", + "selectDashboard": "Dashboard auswählen", + "name": "Name", + "palette": "Widgets", + "paletteEmpty": "Keine Widgets verfügbar.", + "newTab": "Neuer Tab", + "addTab": "Tab hinzufügen", + "removeTab": "Tab entfernen", + "renameTab": "Tab umbenennen", + "tabName": "Tab-Name", + "widgetSettings": "Widget-Einstellungen", + "noSettings": "Keine Einstellungen verfügbar.", + "removeWidget": "Widget entfernen", + "wider": "Breiter", + "narrower": "Schmaler", + "taller": "Höher", + "shorter": "Niedriger" } }, "companies": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 03597ad..3c8229c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -138,6 +138,30 @@ "down": "Down", "unknown": "Unknown" } + }, + "builder": { + "edit": "Edit", + "save": "Save", + "create": "New dashboard", + "delete": "Delete dashboard", + "deleteConfirm": "Really delete dashboard \"{{name}}\"?", + "setDefault": "Set as default", + "selectDashboard": "Select dashboard", + "name": "Name", + "palette": "Widgets", + "paletteEmpty": "No widgets available.", + "newTab": "New tab", + "addTab": "Add tab", + "removeTab": "Remove tab", + "renameTab": "Rename tab", + "tabName": "Tab name", + "widgetSettings": "Widget settings", + "noSettings": "No settings available.", + "removeWidget": "Remove widget", + "wider": "Wider", + "narrower": "Narrower", + "taller": "Taller", + "shorter": "Shorter" } }, "companies": { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f9ec6d8..a6e655a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,9 +1,8 @@ import { useTranslation } from 'react-i18next'; import { StatCard } from '@/components/shared/StatCard'; import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed'; -import { DashboardGrid } from '@/components/dashboard/DashboardGrid'; +import { DashboardBuilder } from '@/components/dashboard/DashboardBuilder'; import { useUnifiedContacts, useAuditLog } from '@/api/hooks'; -import { useDashboardWidgets } from '@/api/dashboard'; import { useSystemDashboard } from '@/api/systemDashboard'; import { useAuthStore } from '@/store/authStore'; import { formatDateTime } from '@/utils/date'; @@ -14,7 +13,6 @@ export function DashboardPage() { const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company'); const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person'); const { data: auditData, isError: auditError } = useAuditLog(1, 5); - const { data: widgetsData, isError: widgetsError } = useDashboardWidgets(); const { data: systemData } = useSystemDashboard(); const user = useAuthStore((s) => s.user); const isAdmin = user?.is_system_admin ?? false; @@ -49,12 +47,15 @@ export function DashboardPage() { avatarUrl: null, })); - const widgets = widgetsData?.items ?? []; - return (

{t('dashboard.title')}

+ {/* Personal dashboard builder (Phase M3) — replaces the old plugin widget grid */} +
+ +
+
- {/* System Metrics (Admin only) — I-DASH, I-COST, I-USE */} + {/* System Metrics (Admin only) — I-DASH, I-COST, I-USE (M4: becomes system MiniApp) */} {isAdmin && systemData && (

{t('dashboard.systemMetrics', 'System Status')}

@@ -144,18 +145,6 @@ export function DashboardPage() {
)} - {/* Dynamic Plugin Widgets */} - {widgetsError ? ( -

- {t('dashboard.widgetsUnavailable')} -

- ) : widgets.length > 0 ? ( -
-

{t('dashboard.widgets')}

- -
- ) : null} - {auditError ? (

{t('dashboard.activityUnavailable')}