Skip to content

AI Prompt: Forma3D.Connect — ECharts Dashboard Analytics

Purpose: Instruct an AI to implement an analytics dashboard with Apache ECharts donut charts, trend lines, and KPI metrics
Estimated Effort: 7–11 days (5 phases)
Prerequisites: None — greenfield analytics feature
Research: docs/03-architecture/research/echarts-dashboard-analytics-research.md
Output: A fully functional analytics dashboard with enhanced KPI tiles, donut charts for orders/print jobs/shipments (with shared period dropdown: Today / Last Week / Last Month / All Time), weekly revenue bar chart, and 30-day order trend line
Status:DONE


🎯 Mission

Add visual analytics to the Forma3D.Connect dashboard using Apache ECharts. The current dashboard (apps/web/src/pages/dashboard.tsx) only shows stat cards and lists — no charts, no trend analysis, no revenue breakdowns.

Problem being solved:

  • Operators have no visual overview of order/print/shipping pipelines
  • No revenue aggregation or trend visibility
  • No print success/failure rate visualization
  • Business health and growth are invisible from the dashboard
  • Stat cards show flat numbers with no comparison to previous period (no "+3 from yesterday")

Solution: Replace the 4 existing simple stat cards with enhanced KPI tiles that include trend indicators ("+3 from yesterday", "No change", etc.), then add ECharts-powered donut charts for orders, print jobs, and shipments (grouped by status), a revenue trend bar chart (weekly), and an order volume trend line (30 days). The rest of the dashboard (System Health, Recent Orders, Active Print Jobs, Welcome Card) stays untouched.

Deliverables:

  • Enhanced KPI stat cards with trend indicators (comparison with previous period)
  • ECharts foundation: on-demand imports, custom dark theme, reusable <ChartCard> wrapper
  • <AnalyticsPeriodDropdown> — shared dropdown (Today / Last Week / Last Month / All Time) controlling all 3 donut charts
  • <OrderStatusChart> — donut chart with count + % + EUR per status
  • <PrintJobStatusChart> — donut chart with count + % per status
  • <ShipmentStatusChart> — donut chart with count + % per status
  • <RevenueTrendChart> — bar chart showing current week's daily revenue in EUR
  • <OrderTrendChart> — line chart showing last 30 days order volume
  • Backend analytics endpoints: /api/v1/analytics/orders, /api/v1/analytics/print-jobs, /api/v1/analytics/shipments, /api/v1/analytics/trends
  • Backend enhancement to /api/v1/dashboard/stats: add comparison deltas (yesterday vs today)
  • TanStack Query hooks for all analytics data
  • Dashboard page integration with responsive layout

Critical constraints:

  • Use on-demand ECharts imports to minimize bundle size (~150 KB gz, not ~320 KB)
  • Follow existing patterns: repository layer for Prisma, service layer for business logic, controller for HTTP
  • All endpoints scoped to tenantId from authenticated session
  • Shared API types go in libs/domain-contracts/src/api/analytics.api.ts
  • No any, no ts-ignore, no eslint-disable
  • Charts must work in dark mode (existing theme uses CSS variables)
  • Charts must be responsive (stack vertically on mobile)

📌 Context (Current State)

What Exists

Dashboard Page (apps/web/src/pages/dashboard.tsx):

  • System Health Card (status, database, uptime, version)
  • 4 StatCards: Pending Orders, Processing, Active Print Jobs, Needs Attention
  • These stat cards must be REPLACED with enhanced KPI tiles matching the mockup (docs/03-architecture/research/assets/echarts-mockups/mockup-dashboard-full-layout.png), which include:
    • Colored icons (shopping cart, gear, printer, warning triangle)
    • Trend comparison indicators ("+3 from yesterday", "→ No change", "↑ +5 from yesterday", "● 2 errors, 2 delays")
    • Same 4 metrics, better visual design
  • Recent Orders list (last 5)
  • Active Print Jobs list (last 5)
  • Welcome Card (static branding)
  • No charts, no analytics

Dashboard Hook (apps/web/src/hooks/use-dashboard.ts):

  • useDashboardStats() — queries apiClient.dashboard.getStats() with 30s polling
  • useActivePrintJobs() — queries active print jobs

Dashboard Stats Type (libs/domain-contracts/src/api/dashboard.api.ts):

export interface DashboardStatsApiResponse {
  pendingOrders: number;
  processingOrders: number;
  completedToday: number;
  failedOrders: number;
  activePrintJobs: number;
  completedPrintJobsToday: number;
  queuedPrintJobs?: number;
  failedPrintJobs?: number;
  printersOnline?: number;
  averagePrintTime?: number | null;
}

API Client (apps/web/src/lib/api-client.ts):

  • Centralized apiClient object with nested namespaces
  • request<T>() helper using fetch with credentials: 'include'
  • Query params via URLSearchParams

Backend Patterns:

  • Controllers: @Controller('api/v1/{resource}') with @ApiTags, @RequirePermissions
  • Services: @Injectable() with repository injection, EventEmitter2, EventLogService
  • Repositories: @Injectable() with PrismaService injection, groupBy() for aggregations
  • Modules: standard NestJS @Module() with imports, controllers, providers, exports
  • Guards: global SessionGuard + PermissionsGuard, use @RequirePermissions(PERMISSIONS.X) decorator

Prisma Models & Indexes:

  • Order: indexes on [tenantId], [status], [createdAt] — has totalPrice (Decimal 10,2), currency (default "EUR")
  • PrintJob: indexes on [tenantId], [status] — has estimatedDuration, actualDuration, retryCount
  • Shipment: indexes on [tenantId], [status], [createdAt] — has weight (Decimal 10,3)
  • LineItem: has unitPrice (Decimal 10,2), quantity (Int)
  • No composite indexes on (tenantId, status, createdAt) — should be added for analytics queries

Status Enums:

Model Statuses
OrderStatus PENDING, PROCESSING, PARTIALLY_COMPLETED, COMPLETED, FAILED, CANCELLED
PrintJobStatus QUEUED, ASSIGNED, PRINTING, COMPLETED, FAILED, CANCELLED
ShipmentStatus PENDING, LABEL_CREATED, ANNOUNCED, IN_TRANSIT, DELIVERED, FAILED, CANCELLED

Chart Libraries Installed: None. This is a greenfield addition.

Frontend Stack: React 19, Vite 7, Tailwind CSS v4, TanStack Query v5, React Router v6, Heroicons, clsx

What's Missing

  1. ECharts dependency — not installed
  2. Analytics endpoints — no /api/v1/analytics/* routes
  3. Analytics module — no AnalyticsModule, AnalyticsService, AnalyticsRepository, AnalyticsController
  4. Analytics shared types — no analytics.api.ts in libs/domain-contracts
  5. Chart components — no charting components anywhere
  6. Stat card trend indicators — existing stat cards show flat counts with no comparison (no "+3 from yesterday" deltas)
  7. Dashboard stats comparison data — backend getStats() returns current counts only, not comparison with previous period
  8. Composite database indexes — no (tenantId, status, createdAt) for efficient analytics queries

Design Decisions

Donut Chart Timespan

Note: The mockup (docs/03-architecture/research/assets/echarts-mockups/mockup-dashboard-full-layout.png) does not explicitly show a time-period selector on the donut charts. However, operators need the ability to slice data by time range to answer questions like "how many orders failed this week?"

Chosen approach: A single shared dropdown above the donut charts row controls the time period for all 3 donut charts simultaneously. The dropdown options are:

Value Label Backend dateFrom
today Today Start of today (00:00)
week Last Week 7 days ago
month Last Month 30 days ago
all All Time No date filter

Default: all (All Time) — matches the mockup's apparent all-time totals ("500+" orders, etc.).

UX design:

  • The dropdown is placed above the 3 donut charts row, right-aligned, as a single compact <select> or styled dropdown
  • Changing the dropdown re-fetches all 3 donut charts with the selected period
  • All 3 hooks share the same period state from useState in the dashboard page
  • The dropdown does NOT affect the KPI stat cards or the trend charts (those have their own fixed timespans)

Trend Chart Timespans

The mockup shows fixed timespans for trend charts:

  • Revenue bar chart: "Revenue This Week" — always shows Mon–Sun of the current week
  • Order trend line: "Order Trend — Last 30 Days" — always shows the last 30 calendar days

These are fixed views, not user-selectable. Selectable day ranges (7d/30d/90d) can be a future enhancement.


🛠️ Tech Stack Reference

New Dependencies

echarts           ^5.6.0    # Core charting library (Apache Foundation)
echarts-for-react ^3.0.6    # Thin React wrapper (3 KB gzipped)

Existing Stack (No Changes)

  • Frontend: React 19, Vite 7, Tailwind CSS v4, TanStack Query v5
  • Backend: NestJS, Prisma, PostgreSQL
  • Testing: Vitest (frontend), Jest (backend)
  • Monorepo: Nx with pnpm

🏗️ Architecture Requirements

1) ECharts On-Demand Imports

Do NOT import the full echarts package. Use selective imports to reduce bundle from ~320 KB to ~150 KB gzipped:

// libs/ui/src/charts/echarts-setup.ts
import * as echarts from 'echarts/core';
import { PieChart, BarChart, LineChart, GaugeChart } from 'echarts/charts';
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([
  PieChart,
  BarChart,
  LineChart,
  GaugeChart,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  CanvasRenderer,
]);

export { echarts };

Use ReactEChartsCore from echarts-for-react/lib/core (not the default export) to leverage on-demand imports.

2) React 19 Peer Dependency Override

Add to root package.json:

{
  "pnpm": {
    "peerDependencyRules": {
      "allowedVersions": {
        "echarts-for-react>react": "19",
        "echarts-for-react>react-dom": "19"
      }
    }
  }
}

3) Backend Analytics Module

Follow the existing module pattern (see OrdersModule):

apps/api/src/analytics/
├── analytics.module.ts
├── analytics.controller.ts
├── analytics.service.ts
├── analytics.repository.ts
└── dto/
    ├── analytics-query.dto.ts
    └── analytics-response.dto.ts

4) Shared API Types

Add to libs/domain-contracts/src/api/analytics.api.ts and re-export from the barrel file.

5) Database Optimization

Add composite indexes for analytics queries via a Prisma migration:

model Order {
  @@index([tenantId, status, createdAt])
}

model PrintJob {
  @@index([tenantId, status, createdAt])
}

model Shipment {
  @@index([tenantId, status, createdAt])
}

📁 Files to Create/Modify

Backend (NestJS)

apps/api/src/analytics/
├── analytics.module.ts                    # CREATE: NestJS module
├── analytics.controller.ts               # CREATE: REST endpoints
├── analytics.service.ts                  # CREATE: Business logic
├── analytics.repository.ts              # CREATE: Prisma queries
└── dto/
    ├── analytics-query.dto.ts            # CREATE: Query param DTO (shared period for donuts)
    └── analytics-response.dto.ts         # CREATE: Swagger response schemas

apps/api/src/dashboard/                    # UPDATE: enhance existing dashboard stats
├── dashboard.service.ts                  # UPDATE: add comparison deltas (yesterday vs today)
├── dashboard.controller.ts              # UPDATE: return enhanced stats with deltas

apps/api/src/app.module.ts               # UPDATE: import AnalyticsModule

prisma/schema.prisma                     # UPDATE: add composite indexes

Shared Types

libs/domain-contracts/src/api/
├── analytics.api.ts                      # CREATE: Shared request/response types
└── index.ts                              # UPDATE: re-export analytics types

Frontend (React)

libs/ui/src/charts/
├── echarts-setup.ts                      # CREATE: On-demand imports + theme
├── chart-card.tsx                        # CREATE: Reusable chart wrapper with time tabs
├── donut-chart.tsx                       # CREATE: Generic donut chart component
├── bar-chart.tsx                         # CREATE: Generic bar chart component
├── line-chart.tsx                        # CREATE: Generic line chart component
├── chart-colors.ts                       # CREATE: Color constants for chart statuses
└── index.ts                              # CREATE: Barrel export

apps/web/src/components/analytics/
├── analytics-period-dropdown.tsx         # CREATE: Shared period dropdown (Today / Last Week / Last Month / All Time)
├── order-status-chart.tsx                # CREATE: Orders donut chart
├── print-job-status-chart.tsx            # CREATE: Print jobs donut chart
├── shipment-status-chart.tsx             # CREATE: Shipments donut chart
├── revenue-trend-chart.tsx               # CREATE: Revenue bar chart
├── order-trend-chart.tsx                 # CREATE: Order volume line chart
└── index.ts                              # CREATE: Barrel export

apps/web/src/hooks/
├── use-analytics.ts                      # CREATE: TanStack Query hooks for analytics

apps/web/src/lib/api-client.ts           # UPDATE: add analytics namespace

apps/web/src/pages/dashboard.tsx         # UPDATE: integrate chart components

package.json                              # UPDATE: add echarts dependencies + peer dep rules

🔧 Implementation Details

Phase 1: Foundation

1.1 Install Dependencies

pnpm add echarts echarts-for-react

Add peer dependency rules to root package.json:

{
  "pnpm": {
    "peerDependencyRules": {
      "allowedVersions": {
        "echarts-for-react>react": "19",
        "echarts-for-react>react-dom": "19"
      }
    }
  }
}

1.2 ECharts Setup Module

Create libs/ui/src/charts/echarts-setup.ts:

import * as echarts from 'echarts/core';
import { PieChart, BarChart, LineChart, GaugeChart } from 'echarts/charts';
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([
  PieChart,
  BarChart,
  LineChart,
  GaugeChart,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  CanvasRenderer,
]);

// Register custom Forma3D dark theme
echarts.registerTheme('forma3d', {
  backgroundColor: 'transparent',
  textStyle: {
    color: '#94a3b8', // Slate-400 — matches theme-tertiary
  },
  title: {
    textStyle: { color: '#f1f5f9' }, // Slate-100
    subtextStyle: { color: '#94a3b8' }, // Slate-400
  },
  legend: {
    textStyle: { color: '#94a3b8' },
  },
  tooltip: {
    backgroundColor: '#1e293b', // Slate-800
    borderColor: '#334155', // Slate-700
    textStyle: { color: '#f1f5f9' },
  },
});

export { echarts };
export type { EChartsOption } from 'echarts';

1.3 Chart Color Constants

Create libs/ui/src/charts/chart-colors.ts:

/** Order status colors — matching apps/web/src/lib/constants.ts */
export const ORDER_STATUS_CHART_COLORS: Record<string, string> = {
  PENDING: '#9CA3AF',
  PROCESSING: '#3B82F6',
  PARTIALLY_COMPLETED: '#F59E0B',
  COMPLETED: '#10B981',
  FAILED: '#EF4444',
  CANCELLED: '#6B7280',
};

/** Print job status colors */
export const PRINT_JOB_STATUS_CHART_COLORS: Record<string, string> = {
  QUEUED: '#60A5FA',
  ASSIGNED: '#818CF8',
  PRINTING: '#A855F7',
  COMPLETED: '#10B981',
  FAILED: '#EF4444',
  CANCELLED: '#6B7280',
};

/** Shipment status colors */
export const SHIPMENT_STATUS_CHART_COLORS: Record<string, string> = {
  PENDING: '#9CA3AF',
  LABEL_CREATED: '#F59E0B',
  ANNOUNCED: '#60A5FA',
  IN_TRANSIT: '#818CF8',
  DELIVERED: '#10B981',
  FAILED: '#EF4444',
  CANCELLED: '#6B7280',
};

1.4 Reusable ChartCard Wrapper

Create libs/ui/src/charts/chart-card.tsx:

A <ChartCard> component that wraps any chart with:

  • Title and optional subtitle (e.g., "Revenue This Week", "Total Orders: 1,850")
  • Optional summary value displayed prominently (e.g., "$4,600")
  • Loading spinner state
  • Empty state when no data
  • Responsive container that passes width/height to ECharts
interface ChartCardProps {
  title: string;
  subtitle?: string;
  /** Optional prominent summary value, e.g., "$4,600" */
  summaryValue?: string;
  loading?: boolean;
  empty?: boolean;
  children: React.ReactNode;
}

Note: The <ChartCard> itself does NOT contain period selector tabs. Time-period filtering for the donut charts is handled by a single shared <AnalyticsPeriodDropdown> placed above the donut row in the dashboard layout. The <ChartCard> just renders the title, subtitle, and chart content.

1.5 Generic Donut Chart Component

Create libs/ui/src/charts/donut-chart.tsx:

A reusable <DonutChart> that accepts:

interface DonutChartSegment {
  name: string;
  value: number;
  color: string;
  /** Optional extra data displayed in tooltip (e.g., EUR amount) */
  extra?: Record<string, string | number>;
}

interface DonutChartProps {
  segments: DonutChartSegment[];
  /** Text displayed in the center of the donut */
  centerLabel?: string;
  centerSubLabel?: string;
  /** Tooltip formatter — receives segment data */
  tooltipFormatter?: (segment: DonutChartSegment) => string;
  /** Fired when a slice is clicked */
  onSliceClick?: (segment: DonutChartSegment) => void;
  height?: number | string;
}

Implementation notes:

  • Use ReactEChartsCore from echarts-for-react/lib/core with our registered echarts instance
  • Apply theme="forma3d" for dark mode
  • Donut via radius: ['40%', '70%']
  • Leader-line labels: label.position: 'outside' with labelLine: { show: true }
  • Center text via graphic component (absolute positioned text in the donut hole)
  • Click handler via onEvents={{ click: handler }}
  • notMerge={true} and lazyUpdate={true} for performance

Phase 2: Backend Analytics API

2.1 Shared Analytics Types

Create libs/domain-contracts/src/api/analytics.api.ts:

// ─── Query ────────────────────────────────────
/** Shared period for all 3 donut charts (single dropdown) */
export type AnalyticsPeriod = 'today' | 'week' | 'month' | 'all';

// ─── Order Analytics ──────────────────────────
export interface OrderStatusMetric {
  status: string;
  count: number;
  percentage: number;
  totalValue: number; // EUR
}

export interface OrderAnalyticsApiResponse {
  period: AnalyticsPeriod;
  totalOrders: number;
  totalRevenue: number;
  avgOrderValue: number;
  statuses: OrderStatusMetric[];
}

// ─── Print Job Analytics ──────────────────────
export interface PrintJobStatusMetric {
  status: string;
  count: number;
  percentage: number;
}

export interface PrintJobAnalyticsApiResponse {
  period: AnalyticsPeriod;
  totalJobs: number;
  avgDurationSeconds: number | null;
  successRate: number;
  retryRate: number;
  statuses: PrintJobStatusMetric[];
}

// ─── Shipment Analytics ───────────────────────
export interface ShipmentStatusMetric {
  status: string;
  count: number;
  percentage: number;
}

export interface ShipmentAnalyticsApiResponse {
  period: AnalyticsPeriod;
  totalShipments: number;
  deliveryRate: number;
  avgDeliveryDays: number | null;
  statuses: ShipmentStatusMetric[];
}

// ─── Trends ───────────────────────────────────
export type TrendMetric = 'orders' | 'revenue';

export interface TrendDataPoint {
  date: string; // ISO date string (YYYY-MM-DD)
  value: number;
}

export interface TrendsApiResponse {
  metric: TrendMetric;
  period: string; // e.g., "This Week", "Last 30 Days"
  summaryValue: number; // e.g., total revenue or total orders for the period
  dataPoints: TrendDataPoint[];
}

// ─── Dashboard Stats Enhancement ──────────────
/** Delta/comparison data for KPI stat cards */
export interface StatDelta {
  /** Absolute change from previous period (e.g., +3) */
  change: number;
  /** Human-readable label (e.g., "from yesterday") */
  label: string;
}

export interface EnhancedDashboardStatsApiResponse {
  pendingOrders: number;
  pendingOrdersDelta?: StatDelta;
  processingOrders: number;
  processingOrdersDelta?: StatDelta;
  activePrintJobs: number;
  activePrintJobsDelta?: StatDelta;
  needsAttention: number;
  needsAttentionDetails?: string; // e.g., "2 errors, 2 delays"
}

Update libs/domain-contracts/src/api/index.ts to re-export.

2.2 Analytics Repository

Create apps/api/src/analytics/analytics.repository.ts:

@Injectable()
export class AnalyticsRepository {
  constructor(private readonly prisma: PrismaService) {}

  /** Order status distribution, optionally filtered by dateFrom */
  async getOrderStatusCounts(
    tenantId: string,
    dateFrom?: Date
  ): Promise<Array<{ status: string; _count: number; _sum_totalPrice: number }>> {
    // Use prisma.order.groupBy with:
    //   by: ['status']
    //   where: { tenantId, createdAt: dateFrom ? { gte: dateFrom } : undefined }
    //   _count: { id: true }
    //   _sum: { totalPrice: true }
  }

  /** Print job status distribution, optionally filtered by dateFrom */
  async getPrintJobStatusCounts(
    tenantId: string,
    dateFrom?: Date
  ): Promise<Array<{ status: string; _count: number }>> {
    // Use prisma.printJob.groupBy with:
    //   by: ['status']
    //   where: { tenantId, createdAt: dateFrom ? { gte: dateFrom } : undefined }
    //   _count: { id: true }
  }

  async getPrintJobDurationStats(
    tenantId: string,
    dateFrom?: Date
  ): Promise<{ avgDuration: number | null; retryCount: number; totalCompleted: number }> {
    // Use prisma.printJob.aggregate with _avg on actualDuration
    // and _sum on retryCount, filtered by dateFrom
  }

  /** Shipment status distribution, optionally filtered by dateFrom */
  async getShipmentStatusCounts(
    tenantId: string,
    dateFrom?: Date
  ): Promise<Array<{ status: string; _count: number }>> {
    // Use prisma.shipment.groupBy
    // where: { tenantId, createdAt: dateFrom ? { gte: dateFrom } : undefined }
  }

  /** Order count trend: daily counts from dateFrom to now */
  async getOrderTrend(
    tenantId: string,
    dateFrom: Date
  ): Promise<Array<{ date: string; count: number }>> {
    // Use raw SQL or Prisma groupBy with date truncation
    // GROUP BY DATE(createdAt) ORDER BY date
  }

  /** Revenue trend: daily revenue from dateFrom to now */
  async getRevenueTrend(
    tenantId: string,
    dateFrom: Date
  ): Promise<Array<{ date: string; revenue: number }>> {
    // Similar to order trend but SUM(totalPrice)
  }

  /** Comparison counts for KPI stat cards (today vs yesterday) */
  async getOrderCountByDate(tenantId: string, dateFrom: Date, dateTo: Date): Promise<number> {
    // prisma.order.count where createdAt between dateFrom and dateTo
  }
}

Important: Use prisma.order.groupBy() for status counts — it maps directly to SQL GROUP BY which is very efficient with the composite index. For daily trends, raw SQL with DATE(created_at) grouping may be needed since Prisma doesn't natively support date truncation in groupBy.

2.3 Analytics Service

Create apps/api/src/analytics/analytics.service.ts:

@Injectable()
export class AnalyticsService {
  constructor(private readonly analyticsRepository: AnalyticsRepository) {}

  /** Returns orders grouped by status for the given period */
  async getOrderAnalytics(
    tenantId: string,
    period: AnalyticsPeriod
  ): Promise<OrderAnalyticsApiResponse> {
    const dateFrom = this.getDateFrom(period);
    const statusCounts = await this.analyticsRepository.getOrderStatusCounts(tenantId, dateFrom);

    const totalOrders = statusCounts.reduce((sum, s) => sum + s._count, 0);
    const totalRevenue = statusCounts.reduce((sum, s) => sum + s._sum_totalPrice, 0);

    return {
      period,
      totalOrders,
      totalRevenue,
      avgOrderValue: totalOrders > 0 ? totalRevenue / totalOrders : 0,
      statuses: statusCounts.map((s) => ({
        status: s.status,
        count: s._count,
        percentage: totalOrders > 0 ? Math.round((s._count / totalOrders) * 100) : 0,
        totalValue: s._sum_totalPrice,
      })),
    };
  }

  // Similar methods for print jobs and shipments (same period param pattern)...

  private getDateFrom(period: AnalyticsPeriod): Date | undefined {
    const now = new Date();
    switch (period) {
      case 'today':
        return new Date(now.getFullYear(), now.getMonth(), now.getDate());
      case 'week':
        const weekAgo = new Date(now);
        weekAgo.setDate(now.getDate() - 7);
        weekAgo.setHours(0, 0, 0, 0);
        return weekAgo;
      case 'month':
        const monthAgo = new Date(now);
        monthAgo.setDate(now.getDate() - 30);
        monthAgo.setHours(0, 0, 0, 0);
        return monthAgo;
      case 'all':
        return undefined;
    }
  }

  /** Revenue trend for the current week (Mon–Sun) */
  async getRevenueTrend(tenantId: string): Promise<TrendsApiResponse> {
    const weekStart = this.getStartOfWeek();
    const dataPoints = await this.analyticsRepository.getRevenueTrend(tenantId, weekStart);
    const summaryValue = dataPoints.reduce((sum, dp) => sum + dp.revenue, 0);
    return {
      metric: 'revenue',
      period: 'This Week',
      summaryValue,
      dataPoints: dataPoints.map((dp) => ({ date: dp.date, value: dp.revenue })),
    };
  }

  /** Order trend for the last 30 days */
  async getOrderTrend(tenantId: string): Promise<TrendsApiResponse> {
    const thirtyDaysAgo = new Date();
    thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
    const dataPoints = await this.analyticsRepository.getOrderTrend(tenantId, thirtyDaysAgo);
    const summaryValue = dataPoints.reduce((sum, dp) => sum + dp.count, 0);
    return {
      metric: 'orders',
      period: 'Last 30 Days',
      summaryValue,
      dataPoints: dataPoints.map((dp) => ({ date: dp.date, value: dp.count })),
    };
  }

  private getStartOfWeek(): Date {
    const now = new Date();
    const day = now.getDay();
    const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday start
    const weekStart = new Date(now.getFullYear(), now.getMonth(), diff);
    weekStart.setHours(0, 0, 0, 0);
    return weekStart;
  }
}

2.4 Analytics Controller

Create apps/api/src/analytics/analytics.controller.ts:

@ApiTags('Analytics')
@ApiSecurity('api-key')
@Controller('api/v1/analytics')
@SkipThrottle()
@RequirePermissions(PERMISSIONS.ORDERS_READ) // Analytics requires at least read access
export class AnalyticsController {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @Get('orders')
  @ApiOperation({ summary: 'Get order analytics by status' })
  @ApiQuery({ name: 'period', enum: ['today', 'week', 'month', 'all'], required: false })
  async getOrderAnalytics(
    @Query() query: AnalyticsQueryDto,
    @Req() req: AuthenticatedRequest
  ): Promise<OrderAnalyticsApiResponse> {
    return this.analyticsService.getOrderAnalytics(req.tenantId, query.period ?? 'all');
  }

  @Get('print-jobs')
  @ApiOperation({ summary: 'Get print job analytics by status' })
  @ApiQuery({ name: 'period', enum: ['today', 'week', 'month', 'all'], required: false })
  async getPrintJobAnalytics(
    @Query() query: AnalyticsQueryDto,
    @Req() req: AuthenticatedRequest
  ): Promise<PrintJobAnalyticsApiResponse> {
    return this.analyticsService.getPrintJobAnalytics(req.tenantId, query.period ?? 'all');
  }

  @Get('shipments')
  @ApiOperation({ summary: 'Get shipment analytics by status' })
  @ApiQuery({ name: 'period', enum: ['today', 'week', 'month', 'all'], required: false })
  async getShipmentAnalytics(
    @Query() query: AnalyticsQueryDto,
    @Req() req: AuthenticatedRequest
  ): Promise<ShipmentAnalyticsApiResponse> {
    return this.analyticsService.getShipmentAnalytics(req.tenantId, query.period ?? 'all');
  }

  @Get('trends/revenue')
  @ApiOperation({ summary: 'Get revenue trend for the current week' })
  async getRevenueTrend(@Req() req: AuthenticatedRequest): Promise<TrendsApiResponse> {
    return this.analyticsService.getRevenueTrend(req.tenantId);
  }

  @Get('trends/orders')
  @ApiOperation({ summary: 'Get order trend for the last 30 days' })
  async getOrderTrend(@Req() req: AuthenticatedRequest): Promise<TrendsApiResponse> {
    return this.analyticsService.getOrderTrend(req.tenantId);
  }
}

2.5 Analytics DTOs

Create apps/api/src/analytics/dto/analytics-query.dto.ts:

export class AnalyticsQueryDto {
  @ApiPropertyOptional({ enum: ['today', 'week', 'month', 'all'], default: 'all' })
  @IsOptional()
  @IsIn(['today', 'week', 'month', 'all'])
  period?: AnalyticsPeriod;
}

Note: The period param is shared by all 3 donut endpoints (orders, print-jobs, shipments). The frontend sends the same period value to all 3 because a single dropdown controls them. Default is 'all' (All Time). The trend endpoints (revenue, orders) take no query params — they use fixed timespans.

Create apps/api/src/analytics/dto/analytics-response.dto.ts — Swagger response schemas matching the shared types.

2.6 Analytics Module

Create apps/api/src/analytics/analytics.module.ts:

@Module({
  controllers: [AnalyticsController],
  providers: [AnalyticsService, AnalyticsRepository],
  exports: [AnalyticsService],
})
export class AnalyticsModule {}

Import in apps/api/src/app.module.ts.

2.7 Database Migration

Add composite indexes to prisma/schema.prisma:

model Order {
  // ... existing fields
  @@index([tenantId, status, createdAt])
}

model PrintJob {
  // ... existing fields
  @@index([tenantId, status, createdAt])
}

model Shipment {
  // ... existing fields
  @@index([tenantId, status, createdAt])
}

Run: pnpm prisma migrate dev --name add-analytics-composite-indexes


Phase 3: Frontend Analytics Integration

3.1 API Client Extension

Update apps/web/src/lib/api-client.ts — add analytics namespace:

analytics: {
  getOrderAnalytics: (period: AnalyticsPeriod = 'all'): Promise<OrderAnalyticsApiResponse> =>
    request<OrderAnalyticsApiResponse>('/api/v1/analytics/orders', { params: { period } }),

  getPrintJobAnalytics: (period: AnalyticsPeriod = 'all'): Promise<PrintJobAnalyticsApiResponse> =>
    request<PrintJobAnalyticsApiResponse>('/api/v1/analytics/print-jobs', { params: { period } }),

  getShipmentAnalytics: (period: AnalyticsPeriod = 'all'): Promise<ShipmentAnalyticsApiResponse> =>
    request<ShipmentAnalyticsApiResponse>('/api/v1/analytics/shipments', { params: { period } }),

  getRevenueTrend: (): Promise<TrendsApiResponse> =>
    request<TrendsApiResponse>('/api/v1/analytics/trends/revenue'),

  getOrderTrend: (): Promise<TrendsApiResponse> =>
    request<TrendsApiResponse>('/api/v1/analytics/trends/orders'),
},

3.2 Analytics Hooks

Create apps/web/src/hooks/use-analytics.ts:

/** Donut charts — period comes from shared dropdown state */
export function useOrderAnalytics(period: AnalyticsPeriod) {
  return useQuery({
    queryKey: ['analytics', 'orders', period],
    queryFn: () => apiClient.analytics.getOrderAnalytics(period),
    refetchInterval: 60000, // 1 minute
  });
}

export function usePrintJobAnalytics(period: AnalyticsPeriod) {
  return useQuery({
    queryKey: ['analytics', 'print-jobs', period],
    queryFn: () => apiClient.analytics.getPrintJobAnalytics(period),
    refetchInterval: 60000,
  });
}

export function useShipmentAnalytics(period: AnalyticsPeriod) {
  return useQuery({
    queryKey: ['analytics', 'shipments', period],
    queryFn: () => apiClient.analytics.getShipmentAnalytics(period),
    refetchInterval: 60000,
  });
}

/** Trend charts — fixed timespans, no period param */
export function useRevenueTrend() {
  return useQuery({
    queryKey: ['analytics', 'trends', 'revenue'],
    queryFn: () => apiClient.analytics.getRevenueTrend(),
    refetchInterval: 300000, // 5 minutes
  });
}

export function useOrderTrend() {
  return useQuery({
    queryKey: ['analytics', 'trends', 'orders'],
    queryFn: () => apiClient.analytics.getOrderTrend(),
    refetchInterval: 300000, // 5 minutes
  });
}

3.3 Chart Components

Create domain-specific chart components in apps/web/src/components/analytics/:

order-status-chart.tsx:

  • Uses <ChartCard> with title "Orders by Status"
  • Receives period prop from shared dropdown state (does NOT have its own period selector)
  • Uses <DonutChart> with ORDER_STATUS_CHART_COLORS
  • Maps OrderAnalyticsApiResponse.statuses to DonutChartSegment[]
  • Center label: {totalOrders}+ (e.g., "500+") — matches mockup
  • Bottom legend with colored dots: New, Pending, Processing, Completed, Cancelled
  • Tooltip: status name, count, percentage, EUR amount
  • Click handler: navigates to /orders?status={STATUS} via React Router

print-job-status-chart.tsx:

  • Same donut pattern with PRINT_JOB_STATUS_CHART_COLORS
  • Center label: Active: {activeCount} — matches mockup (showing currently active, not total)
  • Bottom legend: Queued, Preparing, Printing, Finished, (Failed)
  • No EUR in this chart (print jobs have no monetary value)

shipment-status-chart.tsx:

  • Same donut pattern with SHIPMENT_STATUS_CHART_COLORS
  • Center label: In Transit: {inTransitCount} — matches mockup (showing current in-transit)
  • Bottom legend: Ready to Ship, Shipped, In Transit, Delivered, Completed, Returned

revenue-trend-chart.tsx:

  • Uses <ChartCard> with title "Revenue This Week" and summary value "$4,600"
  • Uses a generic <BarChart> component
  • Fixed timespan: current week (Mon–Sun) — no selector
  • X-axis: day of week (Mon, Tue, ..., Sun), Y-axis: EUR
  • Value labels above each bar
  • Tooltip: day + EUR amount

order-trend-chart.tsx:

  • Uses <ChartCard> with title "Order Trend — Last 30 Days" and subtitle "Total Orders: 1,850"
  • Uses a generic <LineChart> component
  • Fixed timespan: last 30 calendar days — no selector
  • X-axis: dates, Y-axis: order count
  • Area fill below the line (gradient)
  • Tooltip on hover with date + order count (e.g., "Oct 25: 78 Orders")

3.4 Dashboard Integration

Update apps/web/src/pages/dashboard.tsx:

The 4 existing simple colored StatCard components are REPLACED by enhanced KPI tiles matching the mockup. The rest of the dashboard (System Health, Recent Orders, Active Print Jobs, Welcome Card) stays untouched — only pushed down to make room for the new chart rows.

Layout Order (matching docs/03-architecture/research/assets/echarts-mockups/mockup-dashboard-full-layout.png):
1. Page Header (existing, untouched)
2. System Health Card (existing, untouched)
3. Stats Grid — 4 ENHANCED KPI tiles (REPLACE existing stat cards)
   │ Pending Orders    │ Processing     │ Active Print Jobs │ Needs Attention │
   │ 12                │ 24             │ 30                │ 4               │
   │ ↗ +3 from yesterday│ → No change   │ ↑ +5 from yesterday│ ● 2 errors, 2 delays │
   (4 columns on desktop, 2×2 on mobile — same grid as current)
4. ─── NEW: Donut Charts Section ───
   ┌─────────────────────────────────────────────────────────────────── [Today ▾] ──┐ ← shared period dropdown (right-aligned)
   │ Orders by Status (500+) │ Print Jobs by Status (Active: 30) │ Shipments by Status (In Transit: 45) │
   (3 columns on desktop, stacked on mobile)
   ** Single dropdown controls all 3 donut charts — options: Today / Last Week / Last Month / All Time **
   ** Default: All Time **
5. ─── NEW: Trends Row ───
   │ Revenue This Week ($4,600)    │ Order Trend — Last 30 Days (1,850) │
   (2 columns on desktop, stacked on mobile)
   ** Fixed timespans — not user-selectable **
6. Recent Orders + Active Print Jobs (existing, untouched, moved down)
7. Welcome Card (existing, untouched, moved down)

StatCard enhancement requirements:

  • Extend the existing StatCard component (or create EnhancedStatCard) to accept:
  • delta?: { change: number; label: string } — comparison indicator ("+3 from yesterday")
  • details?: string — secondary detail text ("2 errors, 2 delays")
  • Enhance the backend getStats() endpoint to return comparison data (today vs yesterday counts)
  • Display trend direction arrow: ↗ for positive change, ↘ for negative, → for no change
  • Color the delta text: green for positive, red for negative, gray for no change

Use React.lazy() and <Suspense> for the chart components to avoid blocking initial dashboard render with the ECharts bundle.


Phase 4: Advanced Charts (Optional / P2-P3)

Only implement these after Phases 1–3 are stable and working.

4.1 Revenue Breakdown — Nested Donut

A nested pie chart with:

  • Inner ring: revenue by order status (Completed, Processing, Pending)
  • Outer ring: revenue by product SKU within each status

Requires a new endpoint: GET /api/v1/analytics/revenue-breakdown

4.2 Print Success Gauge

A gauge chart showing successRate from the print job analytics response:

  • Green zone: 90–100%
  • Yellow zone: 75–90%
  • Red zone: < 75%

Uses the GaugeChart already registered in echarts-setup.ts.

4.3 Failure Reasons — Rose/Nightingale Chart

A rose chart showing breakdown of print job failure reasons. Requires extending the print job analytics endpoint with failure categorization.


Phase 5: Polish & QA

5.1 Responsive Design

  • Charts stack vertically on screens < 1024px (lg:grid-cols-3grid-cols-1)
  • Chart height adapts: 300px on desktop, 250px on mobile
  • Legend moves from side to bottom on mobile
  • Chart titles and summary values wrap on very small screens

5.2 Loading & Empty States

  • Show <LoadingSpinner /> inside <ChartCard> while data loads
  • Show a centered "No data available" message when all counts are 0
  • Animate chart transitions when data refreshes (ECharts handles this natively)

5.3 Click-to-Filter Navigation

When a user clicks a donut slice:

  • Orders chart: navigate to /orders?status={STATUS}
  • Print jobs chart: navigate to /orders?printJobStatus={STATUS} (or a future dedicated page)
  • Shipments chart: navigate to /orders?shipmentStatus={STATUS} (or a future dedicated page)

5.4 Accessibility

  • Add aria-label to each chart container: "Orders by status donut chart"
  • Use ECharts aria option for auto-generated chart descriptions
  • Ensure focus management with keyboard navigation for interactive chart elements
  • Sufficient color contrast in chart colors (already verified — all meet WCAG AA)

5.5 Performance Validation

  • Verify bundle size impact: run pnpm nx build web and compare bundle stats
  • Target: < 170 KB gzipped increase from ECharts on-demand imports
  • Dashboard initial load should remain < 3 seconds on 3G connection
  • Charts should render within 200ms after data arrives

🧪 Testing Requirements

Backend Unit Tests

Analytics Repository:

Scenario Description
getOrderStatusCounts with tenant filter (all-time) Returns grouped counts scoped to tenant, no date filter
getOrderStatusCounts with tenant + dateFrom Filters to orders created after dateFrom
getOrderStatusCounts with no data Returns empty array
getPrintJobStatusCounts Same patterns as order tests
getShipmentStatusCounts Same patterns
getPrintJobDurationStats Returns avg duration, retry count
getOrderTrend for 7 days Returns 7 data points
getRevenueTrend for 30 days Returns 30 data points with EUR sums

Analytics Service:

Scenario Description
getOrderAnalytics(tenantId, 'all') Calculates percentages and averages correctly (all-time)
getOrderAnalytics(tenantId, 'today') Filters to today's orders only
getOrderAnalytics(tenantId, 'week') Filters to last 7 days
getRevenueTrend(tenantId) Returns current week's daily data points
getOrderTrend(tenantId) Returns last 30 days' data points
Week start calculation Correctly identifies Monday as start of week
Zero orders Returns zeros without division errors

Analytics Controller:

Scenario Description
GET /api/v1/analytics/orders (no period) Returns 200 with all-time data (default)
GET /api/v1/analytics/orders?period=today Returns 200 with today's data
GET /api/v1/analytics/orders?period=invalid Returns 400 validation error
GET /api/v1/analytics/trends/revenue Returns 200 with current week's data
GET /api/v1/analytics/trends/orders Returns 200 with last 30 days' data
Unauthenticated request Returns 401
Missing permissions Returns 403

Frontend Unit Tests

Analytics Hooks:

Scenario Description
useOrderAnalytics('all') Calls API with ?period=all
useOrderAnalytics('today') Calls API with ?period=today — refetches on period change
useRevenueTrend Calls trends/revenue endpoint
useOrderTrend Calls trends/orders endpoint
Error handling Returns error state on API failure

Chart Components:

Scenario Description
OrderStatusChart renders Renders chart card with correct title
OrderStatusChart loading Shows loading spinner
OrderStatusChart empty Shows empty state message
Click on donut slice Navigates to filtered view

✅ Validation Checklist

Infrastructure

  • pnpm install succeeds without errors (only peer dep info for echarts-for-react)
  • pnpm nx build web succeeds
  • pnpm nx build api succeeds
  • pnpm nx lint web passes
  • pnpm nx lint api passes
  • No TypeScript errors
  • Prisma migration applied successfully

Backend

  • AnalyticsModule created and imported in AppModule
  • AnalyticsController with 5 endpoints (orders, print-jobs, shipments, trends/revenue, trends/orders)
  • AnalyticsService with period-based date filtering and fixed timespans for trends
  • AnalyticsRepository with Prisma groupBy queries
  • Dashboard stats endpoint enhanced with comparison deltas (yesterday vs today)
  • Swagger response DTOs for all endpoints
  • Swagger documentation for all endpoints
  • Tenant scoping on all queries
  • @RequirePermissions applied
  • Composite indexes added to Prisma schema

Shared Types

  • analytics.api.ts created in libs/domain-contracts/src/api/
  • All response interfaces exported and importable from @forma3d/domain-contracts

Frontend

  • ECharts on-demand imports (NOT full import)
  • Custom forma3d theme registered
  • <ChartCard> reusable wrapper (title, subtitle, summary value — no per-chart period tabs)
  • <DonutChart> generic component
  • <AnalyticsPeriodDropdown> shared dropdown (Today / Last Week / Last Month / All Time) controlling all 3 donuts
  • <OrderStatusChart>, <PrintJobStatusChart>, <ShipmentStatusChart> components (receive period from shared dropdown)
  • <RevenueTrendChart> (fixed: current week) and <OrderTrendChart> (fixed: last 30 days)
  • useOrderAnalytics(period), usePrintJobAnalytics(period), useShipmentAnalytics(period), useRevenueTrend, useOrderTrend hooks
  • apiClient.analytics.* methods in API client
  • Existing 4 stat cards REPLACED with enhanced KPI tiles (trend indicators, colored icons)
  • Dashboard page updated with donut chart row + trend chart row
  • Charts lazy-loaded with React.lazy() + <Suspense>
  • Responsive layout (3-col → 1-col on mobile for donuts, 2-col → 1-col for trends)
  • Loading and empty states for all charts
  • Dark mode works correctly

Testing

  • Backend: repository tests for all groupBy queries
  • Backend: service tests for percentage logic and trend timespan calculation
  • Backend: controller tests for endpoint routing and validation
  • Frontend: hook tests for API calls
  • Frontend: component render tests for charts

🎬 Execution Order

Phase 1: Foundation (1–2 days)

  1. Install echarts and echarts-for-react via pnpm
  2. Add peer dependency rules to root package.json
  3. Create libs/ui/src/charts/echarts-setup.ts with on-demand imports and theme
  4. Create libs/ui/src/charts/chart-colors.ts with status color constants
  5. Create libs/ui/src/charts/chart-card.tsx — reusable wrapper (title, subtitle, summary value — no per-chart period tabs)
  6. Create libs/ui/src/charts/donut-chart.tsx — generic donut chart component
  7. Create libs/ui/src/charts/bar-chart.tsx — generic bar chart component
  8. Create libs/ui/src/charts/line-chart.tsx — generic line chart component
  9. Create barrel export at libs/ui/src/charts/index.ts
  10. Verify: pnpm nx build web succeeds, bundle size is < 170 KB gz increase

Phase 2: Backend Analytics API (2–3 days)

  1. Create shared types in libs/domain-contracts/src/api/analytics.api.ts
  2. Update barrel export in libs/domain-contracts/src/api/index.ts
  3. Add composite indexes to prisma/schema.prisma
  4. Run Prisma migration: pnpm prisma migrate dev --name add-analytics-composite-indexes
  5. Create apps/api/src/analytics/analytics.repository.ts
  6. Create apps/api/src/analytics/analytics.service.ts
  7. Create apps/api/src/analytics/dto/analytics-query.dto.ts
  8. Create apps/api/src/analytics/dto/analytics-response.dto.ts
  9. Create apps/api/src/analytics/analytics.controller.ts
  10. Create apps/api/src/analytics/analytics.module.ts
  11. Import AnalyticsModule in apps/api/src/app.module.ts
  12. Write backend unit tests (repository, service, controller)
  13. Verify: pnpm nx build api succeeds, pnpm nx test api passes

Phase 3: Frontend — Enhanced Stat Cards + Chart Components (2–3 days)

  1. Enhance stat cards: Update StatCard component to accept delta and details props
  2. Enhance dashboard stats hook: Update useDashboardStats to consume enhanced API response with deltas
  3. Enhance dashboard stats backend: Update DashboardService.getStats() to compute comparison with yesterday
  4. Update apps/web/src/lib/api-client.ts with analytics namespace
  5. Create apps/web/src/hooks/use-analytics.ts with TanStack Query hooks (donut hooks accept period param)
  6. Create apps/web/src/components/analytics/analytics-period-dropdown.tsx — shared dropdown component
  7. Create apps/web/src/components/analytics/order-status-chart.tsx
  8. Create apps/web/src/components/analytics/print-job-status-chart.tsx
  9. Create apps/web/src/components/analytics/shipment-status-chart.tsx
  10. Create apps/web/src/components/analytics/revenue-trend-chart.tsx (fixed: current week)
  11. Create apps/web/src/components/analytics/order-trend-chart.tsx (fixed: last 30 days)
  12. Create barrel export at apps/web/src/components/analytics/index.ts
  13. Update apps/web/src/pages/dashboard.tsx: replace stat cards + add chart rows (lazy-loaded)
  14. Write frontend tests (hooks, component rendering)
  15. Verify: pnpm nx build web succeeds, pnpm nx test web passes

Phase 4: Advanced Charts — Optional (1–2 days)

  1. Add revenue breakdown endpoint + frontend component (nested donut)
  2. Add print success gauge component
  3. Add failure reasons rose chart (if failure categorization data exists)

Phase 5: Polish & QA (1 day)

  1. Responsive testing on mobile viewports
  2. Loading and empty state verification
  3. Click-to-filter navigation for donut slices
  4. Accessibility audit (aria labels, keyboard navigation)
  5. Bundle size verification: pnpm nx build web --stats
  6. Final build and lint pass: pnpm nx run-many --target=build --all and pnpm nx run-many --target=lint --all

📊 Expected Output

Verification Commands

# Install dependencies
pnpm install

# Run Prisma migration
pnpm prisma migrate dev --name add-analytics-composite-indexes

# Build
pnpm nx build api
pnpm nx build web

# Lint
pnpm nx lint api
pnpm nx lint web

# Test
pnpm nx test api
pnpm nx test web

# Full validation
pnpm nx run-many --target=build --all
pnpm nx run-many --target=lint --all
pnpm nx run-many --target=test --all

Success Criteria

  • KPI tiles enhanced: 4 stat cards show trend indicators ("+3 from yesterday", "No change", etc.) matching the mockup
  • Donut charts: 3 donut charts (orders, print jobs, shipments) with a shared period dropdown (Today / Last Week / Last Month / All Time, default: All Time)
  • Period dropdown: Changing the dropdown re-fetches all 3 donut charts; does NOT affect KPI tiles or trend charts
  • Order donut shows "500+" (or actual total) in center, with count + percentage per status slice
  • Print jobs donut shows "Active: {count}" in center
  • Shipments donut shows "In Transit: {count}" in center
  • Revenue trend: bar chart shows current week (Mon–Sun) with summary value (e.g., "$4,600")
  • Order trend: line chart shows last 30 days with total summary (e.g., "Total Orders: 1,850")
  • All charts render in dark mode with the Forma3D theme
  • Charts are responsive — donut row stacks vertically on mobile, trend row stacks vertically on mobile
  • Loading spinners show while data fetches
  • Empty state messages show when no data exists
  • Clicking a donut slice navigates to the relevant filtered view
  • Bundle size increase from ECharts is < 170 KB gzipped
  • All backend endpoints return correct data scoped to the authenticated tenant
  • Rest of dashboard untouched: System Health, Recent Orders, Active Print Jobs, Welcome Card all remain as-is
  • No TypeScript errors, lint passes, all tests pass

🚫 Constraints and Rules

MUST DO

  • Replace the 4 existing simple stat cards with enhanced KPI tiles (trend indicators, colored icons, comparison deltas)
  • Enhance the backend dashboard stats endpoint to return comparison data (yesterday vs today)
  • Match the mockup (docs/03-architecture/research/assets/echarts-mockups/mockup-dashboard-full-layout.png) for layout, tile design, donut chart style, and trend chart style
  • Use on-demand ECharts imports (NOT import * as echarts from 'echarts')
  • Follow the repository pattern (Prisma queries in repository, not service)
  • Scope all analytics queries to tenantId
  • Use shared types from libs/domain-contracts for API contracts
  • Apply @RequirePermissions on the analytics controller
  • Register the AnalyticsModule in AppModule
  • Add composite database indexes before deploying
  • Use ReactEChartsCore (not the default export) with our echarts instance
  • Lazy-load chart components with React.lazy() to avoid blocking initial render
  • Use the existing Tailwind design system for chart cards
  • Donut charts: single shared period dropdown (Today / Last Week / Last Month / All Time), default All Time
  • Trend charts: fixed timespans (current week for revenue, last 30 days for orders) — no period selector

MUST NOT

  • Import the full echarts package (kills bundle size)
  • Put Prisma queries directly in the service (use repository layer)
  • Hardcode tenantId — always read from authenticated request
  • Use any, ts-ignore, or eslint-disable
  • Use console.log in production code
  • Create a separate analytics page (charts go on the existing dashboard)
  • Break the existing dashboard — System Health, Recent Orders, Active Print Jobs, and Welcome Card must remain untouched (only the 4 stat cards get replaced/enhanced)
  • Add per-chart period tabs to individual donut charts (use one shared dropdown for all 3 instead)
  • Use React class components
  • Skip loading/empty states for charts

END OF PROMPT

This prompt implements a full analytics dashboard for Forma3D.Connect using Apache ECharts. It replaces the 4 basic stat cards with enhanced KPI tiles (with trend indicators), adds donut charts for order/print-job/shipment status distribution (with a shared period dropdown: Today / Last Week / Last Month / All Time), a weekly revenue bar chart, and a 30-day order trend line — transforming the basic stat-card dashboard into a visual operations center for 3D print farm management. The rest of the dashboard (System Health, Recent Orders, Active Print Jobs, Welcome Card) remains untouched.