Skip to content

AI Prompt: Forma3D.Connect — Phase 4: Dashboard MVP ⏳

Purpose: This prompt instructs an AI to implement Phase 4 of Forma3D.Connect
Estimated Effort: 42 hours (~3 weeks)
Prerequisites: Phase 3 completed (Fulfillment Loop - automated fulfillment, cancellation, error recovery)
Output: Administrative web dashboard with order management, product mappings UI, real-time updates, and activity logs
Status:PENDING


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 3 foundation. Your task is to implement Phase 4: Dashboard MVP — creating an administrative web interface that allows operators to monitor orders, manage product mappings, and view system activity.

Phase 4 delivers:

  • React 19 dashboard foundation with Tailwind CSS and authentication
  • Order management UI with list, detail views, and manual actions
  • Product mapping configuration UI with SimplyPrint file selection
  • Real-time updates via Socket.IO
  • Activity logs view with filtering and export

Phase 4 enables operators to:

Monitor Orders → View Print Status → Manage Mappings → Handle Exceptions → Review Logs

📋 Phase 4 Context

What Was Built in Previous Phases

The foundation is already in place:

  • Phase 0: Foundation
  • Nx monorepo with apps/api, apps/web, and shared libs
  • PostgreSQL database with Prisma schema (Order, LineItem, PrintJob, ProductMapping, EventLog, RetryQueue)
  • NestJS backend structure with modules, services, repositories
  • Azure DevOps CI/CD pipeline

  • Phase 1: Shopify Inbound

  • Shopify webhooks receiver with HMAC verification
  • Order storage and status management
  • Product mapping CRUD operations
  • Event logging service
  • OpenAPI/Swagger documentation at /api/docs
  • Aikido Security Platform integration

  • Phase 1b: Observability

  • Sentry error tracking and performance monitoring
  • OpenTelemetry-first architecture
  • Structured JSON logging with Pino and correlation IDs
  • React error boundaries

  • Phase 1c: Staging Deployment

  • Docker images with multi-stage builds
  • Traefik reverse proxy with Let's Encrypt TLS
  • Zero-downtime deployments via Docker Compose
  • Staging environment: https://staging-connect.forma3d.be

  • Phase 1d: Acceptance Testing

  • Playwright + Gherkin acceptance tests
  • Given/When/Then scenarios for deployment verification
  • Azure DevOps pipeline integration

  • Phase 2: SimplyPrint Core

  • SimplyPrint API client with HTTP Basic Auth
  • Automated print job creation from orders
  • Print job status monitoring (webhook + polling)
  • Order-job orchestration with order.ready-for-fulfillment event

  • Phase 3: Fulfillment Loop

  • Automated Shopify fulfillment creation
  • Order cancellation handling
  • Retry queue with exponential backoff
  • Email notifications for critical failures
  • API key authentication for admin endpoints

What Phase 4 Builds

Feature Description Effort
F4.1: Dashboard Foundation React dashboard with routing, layout, and auth 8 hours
F4.2: Order Management UI Order list and detail views with manual actions 12 hours
F4.3: Product Mapping UI Configuration interface for product mappings 10 hours
F4.4: Real-Time Updates Socket.IO integration for live dashboard updates 6 hours
F4.5: Activity Logs UI Event log viewer with filtering and export 6 hours

🛠️ Tech Stack Reference

Frontend technologies for Phase 4:

Package Purpose
react UI framework (v19 already installed)
react-router-dom Client-side routing (already installed)
@tanstack/react-query Server state management
tailwindcss Utility-first CSS (already installed)
socket.io-client WebSocket client for real-time updates
@headlessui/react Unstyled accessible UI components
@heroicons/react Beautiful SVG icons
date-fns Date formatting and manipulation
clsx Conditional className utility
react-hot-toast Toast notifications

Backend additions for Phase 4:

Package Purpose
@nestjs/websockets WebSocket gateway
socket.io Socket.IO server (already installed)

🏗️ Architecture Reference

Detailed Architecture Diagrams

📐 For detailed architecture, refer to the existing PlantUML diagrams:

Diagram Path Description
Web Components docs/architecture/C4_Component_Web.puml Dashboard React component structure
Container View docs/architecture/C4_Container.puml System containers and interactions
Component View docs/architecture/C4_Component.puml Backend component architecture
Order State docs/architecture/C4_Code_State_Order.puml Order status state machine
Print Job State docs/architecture/C4_Code_State_PrintJob.puml Print job status transitions
Domain Model docs/architecture/C4_Code_DomainModel.puml Entity relationships
Fulfillment Flow docs/architecture/C4_Seq_05_Fulfillment.puml Fulfillment sequence diagram

These PlantUML diagrams should be updated as part of Phase 4 completion.

UI Mockups (PlantUML Salt)

📐 Visual wireframes for the dashboard are available in docs/prompts/mockups/:

Mockup File Description
Layout 01-layout.puml Main dashboard layout with sidebar
Dashboard 02-dashboard-overview.puml Stats cards and recent orders
Orders List 03-orders-list.puml Orders table with filters
Order Detail 04-order-detail.puml Order view with print jobs
Mappings List 05-mappings-list.puml Product mappings table
Mapping Form 06-mapping-form.puml Create/edit mapping form
Logs List 07-logs-list.puml Activity logs with export
Login 08-login.puml API key authentication

These mockups are for human reference. Use the code examples below for implementation.

Current Database Schema

The Prisma schema already includes all necessary entities:

model Order {
  id                   String        @id @default(uuid())
  shopifyOrderId       String        @unique
  shopifyOrderNumber   String
  status               OrderStatus   @default(PENDING)
  customerName         String
  customerEmail        String?
  shippingAddress      Json
  totalPrice           Decimal       @db.Decimal(10, 2)
  currency             String        @default("EUR")
  shopifyFulfillmentId String?
  trackingNumber       String?
  trackingUrl          String?
  createdAt            DateTime      @default(now())
  updatedAt            DateTime      @updatedAt
  completedAt          DateTime?
  lineItems            LineItem[]
}

model ProductMapping {
  id                String         @id @default(uuid())
  shopifyProductId  String?        @unique
  sku               String         @unique
  productName       String
  description       String?
  isAssembly        Boolean        @default(false)
  defaultPrintProfile Json?
  isActive          Boolean        @default(true)
  createdAt         DateTime       @default(now())
  updatedAt         DateTime       @updatedAt
  assemblyParts     AssemblyPart[]
}

model EventLog {
  id         String   @id @default(uuid())
  orderId    String?
  printJobId String?
  eventType  String
  severity   String   @default("INFO")
  message    String
  metadata   Json?
  createdAt  DateTime @default(now())
}

Dashboard Architecture Overview

📐 Detailed diagram: docs/architecture/C4_Component_Web.puml

Simplified architecture flow:

┌─────────────────────────────────────────────────────────────────┐
│  React Dashboard (apps/web)                                      │
│  ├── Pages: Dashboard | Orders | Mappings | Logs | Settings     │
│  ├── State: TanStack Query (server) + React Context (client)    │
│  └── Real-time: Socket.IO client                                │
└──────────────────────────────┬──────────────────────────────────┘
                               │
              ┌────────────────┼────────────────┐
              ▼                ▼                ▼
        REST API         Socket.IO        API Client
        /api/v1/*         /events         (Typed)
              └────────────────┼────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│  NestJS Backend (apps/api)                                       │
│  ├── Controllers: Orders | Mappings | Logs | SimplyPrint        │
│  ├── Gateway: WebSocket events bridge                           │
│  └── EventEmitter2: Internal event bus                          │
└─────────────────────────────────────────────────────────────────┘

Real-Time Event Flow

📐 Related diagrams:

  • docs/architecture/C4_Seq_02_OrderStatus.puml (order status changes)
  • docs/architecture/C4_Seq_04_PrintJobSync.puml (print job updates)

Simplified event flow:

Backend Event              WebSocket Gateway              React Dashboard
     │                            │                            │
     │  order.status_changed      │                            │
     ├───────────────────────────►│                            │
     │                            │  order:updated             │
     │                            ├───────────────────────────►│
     │                            │                            │ invalidateQueries()
     │                            │                            │ toast.success()
     │                            │                            │

📁 Files to Create/Modify

Frontend (apps/web)

apps/web/src/
├── components/
│   ├── layout/
│   │   ├── root-layout.tsx          # UPDATE: Add navigation, auth context
│   │   ├── sidebar.tsx              # UPDATE: Full navigation menu
│   │   ├── header.tsx               # UPDATE: User info, notifications bell
│   │   └── mobile-nav.tsx           # Mobile responsive navigation
│   │
│   ├── ui/
│   │   ├── button.tsx               # Reusable button component
│   │   ├── badge.tsx                # Status badges
│   │   ├── card.tsx                 # Card container
│   │   ├── table.tsx                # Data table component
│   │   ├── pagination.tsx           # Pagination controls
│   │   ├── modal.tsx                # Modal dialog
│   │   ├── dropdown.tsx             # Dropdown menu
│   │   ├── input.tsx                # Form input
│   │   ├── select.tsx               # Select dropdown
│   │   ├── loading.tsx              # Loading spinner/skeleton
│   │   └── empty-state.tsx          # Empty state placeholder
│   │
│   ├── orders/
│   │   ├── order-list.tsx           # Order list with filters
│   │   ├── order-row.tsx            # Single order row
│   │   ├── order-detail.tsx         # Order detail view
│   │   ├── order-timeline.tsx       # Event timeline
│   │   ├── order-actions.tsx        # Action buttons (retry, cancel, etc.)
│   │   ├── order-filters.tsx        # Filter controls
│   │   └── print-job-card.tsx       # Print job status card
│   │
│   ├── mappings/
│   │   ├── mapping-list.tsx         # Product mapping list
│   │   ├── mapping-row.tsx          # Single mapping row
│   │   ├── mapping-form.tsx         # Create/edit mapping form
│   │   ├── mapping-detail.tsx       # Mapping detail modal
│   │   ├── assembly-parts-editor.tsx # Assembly parts editor
│   │   └── simplyprint-file-picker.tsx # File picker component
│   │
│   └── logs/
│       ├── log-list.tsx             # Event log list
│       ├── log-row.tsx              # Single log entry
│       ├── log-filters.tsx          # Log filters
│       └── log-detail-modal.tsx     # Log detail modal
│
├── pages/
│   ├── dashboard.tsx                # UPDATE: Dashboard overview with stats
│   ├── orders/
│   │   ├── index.tsx                # Orders list page
│   │   └── [id].tsx                 # Order detail page
│   ├── mappings/
│   │   ├── index.tsx                # Mappings list page
│   │   └── new.tsx                  # Create mapping page
│   ├── logs/
│   │   └── index.tsx                # Activity logs page
│   ├── settings/
│   │   └── index.tsx                # Settings page
│   ├── login.tsx                    # Login page
│   └── not-found.tsx                # 404 page (exists)
│
├── hooks/
│   ├── use-health.ts                # EXISTS: Health check hook
│   ├── use-orders.ts                # Orders data hooks
│   ├── use-mappings.ts              # Product mappings hooks
│   ├── use-logs.ts                  # Event logs hooks
│   ├── use-socket.ts                # Socket.IO connection hook
│   ├── use-auth.ts                  # Authentication hook
│   └── use-simplyprint.ts           # SimplyPrint files/printers hooks
│
├── contexts/
│   ├── auth-context.tsx             # Authentication context
│   └── socket-context.tsx           # Socket.IO context
│
├── lib/
│   ├── api-client.ts                # UPDATE: Extend with all endpoints
│   ├── socket-client.ts             # Socket.IO client setup
│   ├── utils.ts                     # Utility functions
│   └── constants.ts                 # Constants (status colors, etc.)
│
├── types/
│   └── index.ts                     # Frontend-specific types
│
├── router.tsx                       # UPDATE: Add all routes
├── main.tsx                         # Entry point
└── styles.css                       # UPDATE: Tailwind config

Backend Additions (apps/api)

apps/api/src/
├── gateway/
│   ├── gateway.module.ts            # WebSocket module
│   ├── events.gateway.ts            # WebSocket gateway
│   └── __tests__/
│       └── events.gateway.spec.ts   # Gateway tests
│
├── orders/
│   └── orders.service.ts            # UPDATE: Add search/filter methods
│
├── event-log/
│   ├── event-log.controller.ts      # UPDATE: Add list/filter endpoint
│   └── dto/
│       └── event-log-query.dto.ts   # Query params DTO
│
├── simplyprint/
│   └── simplyprint.controller.ts    # ADD: Files/printers endpoints for UI

Shared Library Updates

libs/api-client/src/
├── orders/
│   └── orders.client.ts             # Typed orders API client
├── mappings/
│   └── mappings.client.ts           # Typed mappings API client
├── logs/
│   └── logs.client.ts               # Typed logs API client
└── simplyprint/
    └── simplyprint.client.ts        # SimplyPrint files/printers client

🔧 Feature F4.1: Dashboard Foundation

Requirements Reference

  • UI-001: Dashboard Layout
  • NFR-PE-003: Dashboard Response Time (< 500ms for 95%)

Implementation

1. Install Frontend Dependencies

cd apps/web
pnpm add @tanstack/react-query @headlessui/react @heroicons/react date-fns clsx react-hot-toast socket.io-client

2. Configure TanStack Query

Create apps/web/src/lib/query-client.ts:

import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30 * 1000, // 30 seconds
      gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
      retry: 1,
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 0,
    },
  },
});

3. Create UI Components

Create apps/web/src/components/ui/button.tsx:

import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { clsx } from 'clsx';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'primary', size = 'md', loading, disabled, children, ...props }, ref) => {
    const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';

    const variants = {
      primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
      secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
      danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
      ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
    };

    const sizes = {
      sm: 'px-3 py-1.5 text-sm',
      md: 'px-4 py-2 text-sm',
      lg: 'px-6 py-3 text-base',
    };

    return (
      <button
        ref={ref}
        className={clsx(baseStyles, variants[variant], sizes[size], className)}
        disabled={disabled || loading}
        {...props}
      >
        {loading && (
          <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
          </svg>
        )}
        {children}
      </button>
    );
  }
);

Button.displayName = 'Button';

Create apps/web/src/components/ui/badge.tsx:

import { type ReactNode } from 'react';
import { clsx } from 'clsx';

interface BadgeProps {
  children: ReactNode;
  variant?: 'default' | 'success' | 'warning' | 'danger' | 'info';
  size?: 'sm' | 'md';
}

export function Badge({ children, variant = 'default', size = 'md' }: BadgeProps) {
  const variants = {
    default: 'bg-gray-100 text-gray-800',
    success: 'bg-green-100 text-green-800',
    warning: 'bg-yellow-100 text-yellow-800',
    danger: 'bg-red-100 text-red-800',
    info: 'bg-blue-100 text-blue-800',
  };

  const sizes = {
    sm: 'px-2 py-0.5 text-xs',
    md: 'px-2.5 py-1 text-sm',
  };

  return (
    <span className={clsx('inline-flex items-center font-medium rounded-full', variants[variant], sizes[size])}>
      {children}
    </span>
  );
}

4. Create Status Color Mapping

Create apps/web/src/lib/constants.ts:

import type { OrderStatus, PrintJobStatus } from '@forma3d/domain';

export const ORDER_STATUS_COLORS: Record<
  string,
  { bg: string; text: string; badge: 'default' | 'success' | 'warning' | 'danger' | 'info' }
> = {
  PENDING: { bg: 'bg-gray-100', text: 'text-gray-800', badge: 'default' },
  PROCESSING: { bg: 'bg-blue-100', text: 'text-blue-800', badge: 'info' },
  PARTIALLY_COMPLETED: { bg: 'bg-yellow-100', text: 'text-yellow-800', badge: 'warning' },
  COMPLETED: { bg: 'bg-green-100', text: 'text-green-800', badge: 'success' },
  FAILED: { bg: 'bg-red-100', text: 'text-red-800', badge: 'danger' },
  CANCELLED: { bg: 'bg-gray-100', text: 'text-gray-500', badge: 'default' },
};

export const PRINT_JOB_STATUS_COLORS: Record<
  string,
  { bg: string; text: string; badge: 'default' | 'success' | 'warning' | 'danger' | 'info' }
> = {
  PENDING: { bg: 'bg-gray-100', text: 'text-gray-800', badge: 'default' },
  QUEUED: { bg: 'bg-blue-100', text: 'text-blue-800', badge: 'info' },
  ASSIGNED: { bg: 'bg-indigo-100', text: 'text-indigo-800', badge: 'info' },
  PRINTING: { bg: 'bg-purple-100', text: 'text-purple-800', badge: 'info' },
  COMPLETED: { bg: 'bg-green-100', text: 'text-green-800', badge: 'success' },
  FAILED: { bg: 'bg-red-100', text: 'text-red-800', badge: 'danger' },
  CANCELLED: { bg: 'bg-gray-100', text: 'text-gray-500', badge: 'default' },
};

export const SEVERITY_COLORS: Record<
  string,
  { badge: 'default' | 'success' | 'warning' | 'danger' | 'info' }
> = {
  INFO: { badge: 'info' },
  WARNING: { badge: 'warning' },
  ERROR: { badge: 'danger' },
};

5. Create Authentication Context

Create apps/web/src/contexts/auth-context.tsx:

import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';

interface AuthContextType {
  isAuthenticated: boolean;
  apiKey: string | null;
  login: (apiKey: string) => void;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const AUTH_STORAGE_KEY = 'forma3d_api_key';

export function AuthProvider({ children }: { children: ReactNode }) {
  const [apiKey, setApiKey] = useState<string | null>(() => {
    return localStorage.getItem(AUTH_STORAGE_KEY);
  });

  const login = (key: string) => {
    localStorage.setItem(AUTH_STORAGE_KEY, key);
    setApiKey(key);
  };

  const logout = () => {
    localStorage.removeItem(AUTH_STORAGE_KEY);
    setApiKey(null);
  };

  const value: AuthContextType = {
    isAuthenticated: !!apiKey,
    apiKey,
    login,
    logout,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

6. Update Sidebar Navigation

Update apps/web/src/components/layout/sidebar.tsx:

import { NavLink } from 'react-router-dom';
import {
  HomeIcon,
  ClipboardDocumentListIcon,
  CubeIcon,
  DocumentTextIcon,
  Cog6ToothIcon,
} from '@heroicons/react/24/outline';
import { clsx } from 'clsx';

const navigation = [
  { name: 'Dashboard', href: '/', icon: HomeIcon },
  { name: 'Orders', href: '/orders', icon: ClipboardDocumentListIcon },
  { name: 'Product Mappings', href: '/mappings', icon: CubeIcon },
  { name: 'Activity Logs', href: '/logs', icon: DocumentTextIcon },
  { name: 'Settings', href: '/settings', icon: Cog6ToothIcon },
];

export function Sidebar() {
  return (
    <div className="flex flex-col w-64 bg-gray-900 h-screen fixed left-0 top-0">
      {/* Logo */}
      <div className="flex items-center h-16 px-6 bg-gray-800">
        <span className="text-xl font-bold text-white">Forma3D.Connect</span>
      </div>

      {/* Navigation */}
      <nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
        {navigation.map((item) => (
          <NavLink
            key={item.name}
            to={item.href}
            className={({ isActive }) =>
              clsx(
                'flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors',
                isActive
                  ? 'bg-gray-800 text-white'
                  : 'text-gray-300 hover:bg-gray-800 hover:text-white'
              )
            }
          >
            <item.icon className="w-5 h-5 mr-3" aria-hidden="true" />
            {item.name}
          </NavLink>
        ))}
      </nav>

      {/* Version */}
      <div className="px-6 py-4 border-t border-gray-800">
        <p className="text-xs text-gray-500">Version 0.4.0</p>
      </div>
    </div>
  );
}

7. Update Router Configuration

Update apps/web/src/router.tsx:

import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { RootLayout } from './components/layout/root-layout';
import { Dashboard } from './pages/dashboard';
import { OrdersPage } from './pages/orders';
import { OrderDetailPage } from './pages/orders/[id]';
import { MappingsPage } from './pages/mappings';
import { NewMappingPage } from './pages/mappings/new';
import { LogsPage } from './pages/logs';
import { SettingsPage } from './pages/settings';
import { LoginPage } from './pages/login';
import { NotFound } from './pages/not-found';
import { useAuth } from './contexts/auth-context';

function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
}

const router = createBrowserRouter([
  {
    path: '/login',
    element: <LoginPage />,
  },
  {
    path: '/',
    element: (
      <ProtectedRoute>
        <RootLayout />
      </ProtectedRoute>
    ),
    children: [
      { index: true, element: <Dashboard /> },
      { path: 'orders', element: <OrdersPage /> },
      { path: 'orders/:id', element: <OrderDetailPage /> },
      { path: 'mappings', element: <MappingsPage /> },
      { path: 'mappings/new', element: <NewMappingPage /> },
      { path: 'logs', element: <LogsPage /> },
      { path: 'settings', element: <SettingsPage /> },
    ],
  },
  { path: '*', element: <NotFound /> },
]);

export function Router() {
  return <RouterProvider router={router} />;
}

8. Update Main Entry Point

Update apps/web/src/main.tsx:

import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toaster } from 'react-hot-toast';
import { initSentry } from './observability/sentry';
import { ErrorBoundary } from './observability/ErrorBoundary';
import { AuthProvider } from './contexts/auth-context';
import { SocketProvider } from './contexts/socket-context';
import { queryClient } from './lib/query-client';
import { Router } from './router';
import './styles.css';

// Initialize Sentry
initSentry();

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

root.render(
  <StrictMode>
    <ErrorBoundary>
      <QueryClientProvider client={queryClient}>
        <AuthProvider>
          <SocketProvider>
            <Router />
            <Toaster position="top-right" />
          </SocketProvider>
        </AuthProvider>
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </ErrorBoundary>
  </StrictMode>
);

🔧 Feature F4.2: Order Management UI

Requirements Reference

  • FR-AD-001: Order Queue View
  • FR-AD-002: Order Detail View
  • FR-AD-006: Manual Order Processing

Implementation

1. Create Orders Hook

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

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';
import { useAuth } from '../contexts/auth-context';
import type { Order, OrderStatus } from '@forma3d/domain';

interface OrdersQuery {
  page?: number;
  pageSize?: number;
  status?: OrderStatus;
  search?: string;
  startDate?: string;
  endDate?: string;
}

interface OrdersResponse {
  orders: Order[];
  total: number;
  page: number;
  pageSize: number;
}

export function useOrders(query: OrdersQuery = {}) {
  return useQuery<OrdersResponse>({
    queryKey: ['orders', query],
    queryFn: () => apiClient.getOrders(query),
  });
}

export function useOrder(id: string) {
  return useQuery<Order>({
    queryKey: ['orders', id],
    queryFn: () => apiClient.getOrder(id),
    enabled: !!id,
  });
}

export function useOrderPrintJobs(orderId: string) {
  return useQuery({
    queryKey: ['orders', orderId, 'print-jobs'],
    queryFn: () => apiClient.getOrderPrintJobs(orderId),
    enabled: !!orderId,
  });
}

export function useRetryPrintJob() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: (jobId: string) => apiClient.retryPrintJob(jobId, apiKey),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

export function useCancelOrder() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: (orderId: string) => apiClient.cancelOrder(orderId, apiKey),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

export function useForceFulfill() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: (orderId: string) => apiClient.forceFulfillOrder(orderId, apiKey),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

2. Create Order List Page

Create apps/web/src/pages/orders/index.tsx:

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
import { EyeIcon } from '@heroicons/react/24/outline';
import { useOrders } from '../../hooks/use-orders';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import { ORDER_STATUS_COLORS } from '../../lib/constants';
import type { OrderStatus } from '@forma3d/domain';

const STATUS_OPTIONS: { value: OrderStatus | ''; label: string }[] = [
  { value: '', label: 'All Statuses' },
  { value: 'PENDING', label: 'Pending' },
  { value: 'PROCESSING', label: 'Processing' },
  { value: 'PARTIALLY_COMPLETED', label: 'Partially Completed' },
  { value: 'COMPLETED', label: 'Completed' },
  { value: 'FAILED', label: 'Failed' },
  { value: 'CANCELLED', label: 'Cancelled' },
];

export function OrdersPage() {
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState<OrderStatus | ''>('');
  const [search, setSearch] = useState('');

  const { data, isLoading, error } = useOrders({
    page,
    pageSize: 50,
    status: status || undefined,
    search: search || undefined,
  });

  if (error) {
    return (
      <div className="p-4 bg-red-50 text-red-700 rounded-lg">
        Error loading orders: {error.message}
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-gray-900">Orders</h1>
      </div>

      {/* Filters */}
      <div className="flex flex-wrap gap-4 p-4 bg-white rounded-lg shadow">
        <input
          type="text"
          placeholder="Search by order number or customer..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="flex-1 min-w-[200px] px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
        <select
          value={status}
          onChange={(e) => setStatus(e.target.value as OrderStatus | '')}
          className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        >
          {STATUS_OPTIONS.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      </div>

      {/* Table */}
      <div className="bg-white rounded-lg shadow overflow-hidden">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Order
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Date
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Customer
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Items
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Status
              </th>
              <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="bg-white divide-y divide-gray-200">
            {isLoading ? (
              <tr>
                <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
                  Loading orders...
                </td>
              </tr>
            ) : data?.orders.length === 0 ? (
              <tr>
                <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
                  No orders found
                </td>
              </tr>
            ) : (
              data?.orders.map((order) => (
                <tr key={order.id} className="hover:bg-gray-50">
                  <td className="px-6 py-4 whitespace-nowrap">
                    <div className="text-sm font-medium text-gray-900">
                      #{order.shopifyOrderNumber}
                    </div>
                    <div className="text-xs text-gray-500">{order.id.slice(0, 8)}...</div>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                    {format(new Date(order.createdAt), 'MMM d, yyyy HH:mm')}
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <div className="text-sm text-gray-900">{order.customerName}</div>
                    <div className="text-xs text-gray-500">{order.customerEmail}</div>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                    {order.lineItems?.length || 0} item(s)
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <Badge variant={ORDER_STATUS_COLORS[order.status]?.badge || 'default'}>
                      {order.status}
                    </Badge>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap text-right">
                    <Link to={`/orders/${order.id}`}>
                      <Button variant="ghost" size="sm">
                        <EyeIcon className="w-4 h-4 mr-1" />
                        View
                      </Button>
                    </Link>
                  </td>
                </tr>
              ))
            )}
          </tbody>
        </table>

        {/* Pagination */}
        {data && data.total > data.pageSize && (
          <div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
            <p className="text-sm text-gray-500">
              Showing {(data.page - 1) * data.pageSize + 1} to{' '}
              {Math.min(data.page * data.pageSize, data.total)} of {data.total} orders
            </p>
            <div className="flex gap-2">
              <Button
                variant="secondary"
                size="sm"
                disabled={page === 1}
                onClick={() => setPage((p) => p - 1)}
              >
                Previous
              </Button>
              <Button
                variant="secondary"
                size="sm"
                disabled={page * data.pageSize >= data.total}
                onClick={() => setPage((p) => p + 1)}
              >
                Next
              </Button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

3. Create Order Detail Page

Create apps/web/src/pages/orders/[id].tsx:

import { useParams, useNavigate } from 'react-router-dom';
import { format } from 'date-fns';
import { ArrowLeftIcon, ArrowPathIcon, XMarkIcon, CheckIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useOrder, useOrderPrintJobs, useRetryPrintJob, useCancelOrder, useForceFulfill } from '../../hooks/use-orders';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { ORDER_STATUS_COLORS, PRINT_JOB_STATUS_COLORS } from '../../lib/constants';

export function OrderDetailPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();

  const { data: order, isLoading: orderLoading, error: orderError } = useOrder(id!);
  const { data: printJobs, isLoading: jobsLoading } = useOrderPrintJobs(id!);

  const retryMutation = useRetryPrintJob();
  const cancelMutation = useCancelOrder();
  const forceFulfillMutation = useForceFulfill();

  const handleRetryJob = async (jobId: string) => {
    try {
      await retryMutation.mutateAsync(jobId);
      toast.success('Print job retry initiated');
    } catch (error) {
      toast.error('Failed to retry print job');
    }
  };

  const handleCancelOrder = async () => {
    if (!window.confirm('Are you sure you want to cancel this order?')) return;

    try {
      await cancelMutation.mutateAsync(id!);
      toast.success('Order cancelled');
    } catch (error) {
      toast.error('Failed to cancel order');
    }
  };

  const handleForceFulfill = async () => {
    if (!window.confirm('Force fulfill this order? This will mark it as fulfilled in Shopify.')) return;

    try {
      await forceFulfillMutation.mutateAsync(id!);
      toast.success('Order force fulfilled');
    } catch (error) {
      toast.error('Failed to force fulfill order');
    }
  };

  if (orderLoading) {
    return <div className="p-8 text-center">Loading order...</div>;
  }

  if (orderError || !order) {
    return (
      <div className="p-8">
        <div className="p-4 bg-red-50 text-red-700 rounded-lg">
          Error loading order: {orderError?.message || 'Order not found'}
        </div>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-4">
          <button
            onClick={() => navigate('/orders')}
            className="p-2 hover:bg-gray-100 rounded-lg"
          >
            <ArrowLeftIcon className="w-5 h-5" />
          </button>
          <div>
            <h1 className="text-2xl font-bold text-gray-900">
              Order #{order.shopifyOrderNumber}
            </h1>
            <p className="text-sm text-gray-500">
              Created {format(new Date(order.createdAt), 'PPpp')}
            </p>
          </div>
        </div>
        <Badge variant={ORDER_STATUS_COLORS[order.status]?.badge || 'default'} size="md">
          {order.status}
        </Badge>
      </div>

      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {/* Order Info */}
        <div className="lg:col-span-2 space-y-6">
          {/* Customer Info */}
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-lg font-semibold mb-4">Customer Information</h2>
            <dl className="grid grid-cols-2 gap-4">
              <div>
                <dt className="text-sm text-gray-500">Name</dt>
                <dd className="text-sm font-medium">{order.customerName}</dd>
              </div>
              <div>
                <dt className="text-sm text-gray-500">Email</dt>
                <dd className="text-sm font-medium">{order.customerEmail || '-'}</dd>
              </div>
              <div className="col-span-2">
                <dt className="text-sm text-gray-500">Shipping Address</dt>
                <dd className="text-sm font-medium">
                  {typeof order.shippingAddress === 'object'
                    ? JSON.stringify(order.shippingAddress, null, 2)
                    : order.shippingAddress}
                </dd>
              </div>
            </dl>
          </div>

          {/* Line Items */}
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-lg font-semibold mb-4">Line Items</h2>
            <div className="space-y-4">
              {order.lineItems?.map((item) => (
                <div key={item.id} className="flex items-center justify-between p-4 border rounded-lg">
                  <div>
                    <p className="font-medium">{item.productName}</p>
                    <p className="text-sm text-gray-500">SKU: {item.productSku}</p>
                  </div>
                  <div className="text-right">
                    <p className="font-medium">Qty: {item.quantity}</p>
                    <Badge variant={ORDER_STATUS_COLORS[item.status]?.badge || 'default'}>
                      {item.status}
                    </Badge>
                  </div>
                </div>
              ))}
            </div>
          </div>

          {/* Print Jobs */}
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-lg font-semibold mb-4">Print Jobs</h2>
            {jobsLoading ? (
              <p className="text-gray-500">Loading print jobs...</p>
            ) : printJobs?.length === 0 ? (
              <p className="text-gray-500">No print jobs created yet</p>
            ) : (
              <div className="space-y-4">
                {printJobs?.map((job) => (
                  <div key={job.id} className="flex items-center justify-between p-4 border rounded-lg">
                    <div>
                      <p className="font-medium">Job {job.id.slice(0, 8)}...</p>
                      <p className="text-sm text-gray-500">
                        SimplyPrint: {job.simplyPrintJobId || 'Not created'}
                      </p>
                      {job.errorMessage && (
                        <p className="text-sm text-red-600 mt-1">{job.errorMessage}</p>
                      )}
                    </div>
                    <div className="flex items-center gap-4">
                      <Badge variant={PRINT_JOB_STATUS_COLORS[job.status]?.badge || 'default'}>
                        {job.status}
                      </Badge>
                      {job.status === 'FAILED' && (
                        <Button
                          variant="secondary"
                          size="sm"
                          onClick={() => handleRetryJob(job.id)}
                          loading={retryMutation.isPending}
                        >
                          <ArrowPathIcon className="w-4 h-4 mr-1" />
                          Retry
                        </Button>
                      )}
                    </div>
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>

        {/* Actions Sidebar */}
        <div className="space-y-6">
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-lg font-semibold mb-4">Actions</h2>
            <div className="space-y-3">
              {order.status !== 'COMPLETED' && order.status !== 'CANCELLED' && (
                <Button
                  variant="primary"
                  className="w-full"
                  onClick={handleForceFulfill}
                  loading={forceFulfillMutation.isPending}
                >
                  <CheckIcon className="w-4 h-4 mr-2" />
                  Force Fulfill
                </Button>
              )}
              {order.status !== 'CANCELLED' && (
                <Button
                  variant="danger"
                  className="w-full"
                  onClick={handleCancelOrder}
                  loading={cancelMutation.isPending}
                >
                  <XMarkIcon className="w-4 h-4 mr-2" />
                  Cancel Order
                </Button>
              )}
            </div>
          </div>

          {/* Order Summary */}
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-lg font-semibold mb-4">Summary</h2>
            <dl className="space-y-2">
              <div className="flex justify-between">
                <dt className="text-gray-500">Total</dt>
                <dd className="font-medium">{order.currency} {order.totalPrice}</dd>
              </div>
              {order.trackingNumber && (
                <div className="flex justify-between">
                  <dt className="text-gray-500">Tracking</dt>
                  <dd className="font-medium text-blue-600">
                    <a href={order.trackingUrl || '#'} target="_blank" rel="noopener noreferrer">
                      {order.trackingNumber}
                    </a>
                  </dd>
                </div>
              )}
            </dl>
          </div>
        </div>
      </div>
    </div>
  );
}

🔧 Feature F4.3: Product Mapping UI

Requirements Reference

  • FR-AD-003: Product Mapping Management

Implementation

1. Create Mappings Hook

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

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';
import type { ProductMapping, CreateMappingInput, UpdateMappingInput } from '@forma3d/domain';

interface MappingsQuery {
  page?: number;
  pageSize?: number;
  search?: string;
  isActive?: boolean;
}

interface MappingsResponse {
  mappings: ProductMapping[];
  total: number;
  page: number;
  pageSize: number;
}

export function useMappings(query: MappingsQuery = {}) {
  return useQuery<MappingsResponse>({
    queryKey: ['mappings', query],
    queryFn: () => apiClient.getMappings(query),
  });
}

export function useMapping(id: string) {
  return useQuery<ProductMapping>({
    queryKey: ['mappings', id],
    queryFn: () => apiClient.getMapping(id),
    enabled: !!id,
  });
}

export function useCreateMapping() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateMappingInput) => apiClient.createMapping(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['mappings'] });
    },
  });
}

export function useUpdateMapping() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateMappingInput }) =>
      apiClient.updateMapping(id, data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['mappings'] });
    },
  });
}

export function useToggleMappingStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, activate }: { id: string; activate: boolean }) =>
      activate ? apiClient.activateMapping(id) : apiClient.deactivateMapping(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['mappings'] });
    },
  });
}

export function useDeleteMapping() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => apiClient.deleteMapping(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['mappings'] });
    },
  });
}

2. Create Mappings Page

Create apps/web/src/pages/mappings/index.tsx:

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useMappings, useToggleMappingStatus, useDeleteMapping } from '../../hooks/use-mappings';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';

export function MappingsPage() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  const { data, isLoading, error } = useMappings({
    page,
    pageSize: 50,
    search: search || undefined,
  });

  const toggleMutation = useToggleMappingStatus();
  const deleteMutation = useDeleteMapping();

  const handleToggle = async (id: string, isActive: boolean) => {
    try {
      await toggleMutation.mutateAsync({ id, activate: !isActive });
      toast.success(isActive ? 'Mapping deactivated' : 'Mapping activated');
    } catch (error) {
      toast.error('Failed to update mapping status');
    }
  };

  const handleDelete = async (id: string) => {
    if (!window.confirm('Are you sure you want to delete this mapping?')) return;

    try {
      await deleteMutation.mutateAsync(id);
      toast.success('Mapping deleted');
    } catch (error) {
      toast.error('Failed to delete mapping');
    }
  };

  if (error) {
    return (
      <div className="p-4 bg-red-50 text-red-700 rounded-lg">
        Error loading mappings: {error.message}
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-gray-900">Product Mappings</h1>
        <Link to="/mappings/new">
          <Button>
            <PlusIcon className="w-4 h-4 mr-2" />
            Add Mapping
          </Button>
        </Link>
      </div>

      {/* Search */}
      <div className="p-4 bg-white rounded-lg shadow">
        <input
          type="text"
          placeholder="Search by SKU or product name..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
      </div>

      {/* Table */}
      <div className="bg-white rounded-lg shadow overflow-hidden">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                SKU
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Product Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Type
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Parts
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Status
              </th>
              <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="bg-white divide-y divide-gray-200">
            {isLoading ? (
              <tr>
                <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
                  Loading mappings...
                </td>
              </tr>
            ) : data?.mappings.length === 0 ? (
              <tr>
                <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
                  No mappings found. Create your first mapping to get started.
                </td>
              </tr>
            ) : (
              data?.mappings.map((mapping) => (
                <tr key={mapping.id} className="hover:bg-gray-50">
                  <td className="px-6 py-4 whitespace-nowrap">
                    <span className="font-mono text-sm">{mapping.sku}</span>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <div className="text-sm font-medium text-gray-900">{mapping.productName}</div>
                    {mapping.description && (
                      <div className="text-xs text-gray-500 truncate max-w-xs">
                        {mapping.description}
                      </div>
                    )}
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <Badge variant={mapping.isAssembly ? 'info' : 'default'}>
                      {mapping.isAssembly ? 'Assembly' : 'Single'}
                    </Badge>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                    {mapping.assemblyParts?.length || 0} part(s)
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <button
                      onClick={() => handleToggle(mapping.id, mapping.isActive)}
                      className="focus:outline-none"
                    >
                      <Badge variant={mapping.isActive ? 'success' : 'default'}>
                        {mapping.isActive ? 'Active' : 'Inactive'}
                      </Badge>
                    </button>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap text-right">
                    <div className="flex justify-end gap-2">
                      <Link to={`/mappings/${mapping.id}/edit`}>
                        <Button variant="ghost" size="sm">
                          <PencilIcon className="w-4 h-4" />
                        </Button>
                      </Link>
                      <Button
                        variant="ghost"
                        size="sm"
                        onClick={() => handleDelete(mapping.id)}
                        loading={deleteMutation.isPending}
                      >
                        <TrashIcon className="w-4 h-4 text-red-500" />
                      </Button>
                    </div>
                  </td>
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

🔧 Feature F4.4: Real-Time Updates

Requirements Reference

  • FR-SP-006: Printer Status Visibility (real-time)
  • UI-002: Real-time updates without page refresh

Implementation

1. Create WebSocket Gateway (Backend)

Create apps/api/src/gateway/events.gateway.ts:

import { Logger } from '@nestjs/common';
import {
  WebSocketGateway,
  WebSocketServer,
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { OnEvent } from '@nestjs/event-emitter';
import { Server, Socket } from 'socket.io';
import * as Sentry from '@sentry/nestjs';
import { ORDER_EVENTS } from '../orders/events/order.events';
import { PRINT_JOB_EVENTS } from '../print-jobs/events/print-job.events';

@WebSocketGateway({
  cors: {
    origin: process.env.FRONTEND_URL || 'http://localhost:4200',
    credentials: true,
  },
  namespace: '/events',
})
export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  private readonly logger = new Logger(EventsGateway.name);

  @WebSocketServer()
  server: Server;

  afterInit(server: Server): void {
    this.logger.log('WebSocket Gateway initialized');
  }

  handleConnection(client: Socket): void {
    this.logger.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket): void {
    this.logger.log(`Client disconnected: ${client.id}`);
  }

  // Order Events
  @OnEvent(ORDER_EVENTS.CREATED)
  handleOrderCreated(event: { order: { id: string; shopifyOrderNumber: string } }): void {
    this.server.emit('order:created', {
      id: event.order.id,
      orderNumber: event.order.shopifyOrderNumber,
    });
    this.logger.debug(`Emitted order:created for ${event.order.shopifyOrderNumber}`);
  }

  @OnEvent(ORDER_EVENTS.UPDATED)
  handleOrderUpdated(event: {
    order: { id: string; status: string; shopifyOrderNumber: string };
  }): void {
    this.server.emit('order:updated', {
      id: event.order.id,
      status: event.order.status,
      orderNumber: event.order.shopifyOrderNumber,
    });
    this.logger.debug(`Emitted order:updated for ${event.order.shopifyOrderNumber}`);
  }

  @OnEvent(ORDER_EVENTS.READY_FOR_FULFILLMENT)
  handleOrderReadyForFulfillment(event: {
    order: { id: string; shopifyOrderNumber: string };
  }): void {
    this.server.emit('order:ready-for-fulfillment', {
      id: event.order.id,
      orderNumber: event.order.shopifyOrderNumber,
    });
  }

  @OnEvent(ORDER_EVENTS.CANCELLED)
  handleOrderCancelled(event: { orderId: string }): void {
    this.server.emit('order:cancelled', { id: event.orderId });
  }

  // Print Job Events
  @OnEvent(PRINT_JOB_EVENTS.STATUS_CHANGED)
  handlePrintJobStatusChanged(event: {
    printJob: { id: string; status: string };
    previousStatus: string;
    newStatus: string;
  }): void {
    this.server.emit('printjob:updated', {
      id: event.printJob.id,
      status: event.newStatus,
      previousStatus: event.previousStatus,
    });
    this.logger.debug(`Emitted printjob:updated for ${event.printJob.id}`);
  }

  @OnEvent(PRINT_JOB_EVENTS.FAILED)
  handlePrintJobFailed(event: { printJob: { id: string }; errorMessage?: string }): void {
    this.server.emit('printjob:failed', {
      id: event.printJob.id,
      error: event.errorMessage,
    });
  }

  // Send notification to all clients
  sendNotification(type: 'info' | 'warning' | 'error', message: string, data?: unknown): void {
    this.server.emit('notification', { type, message, data, timestamp: new Date().toISOString() });
  }
}

2. Create Socket Context (Frontend)

Create apps/web/src/contexts/socket-context.tsx:

import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { io, Socket } from 'socket.io-client';
import { useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';

interface SocketContextType {
  socket: Socket | null;
  isConnected: boolean;
}

const SocketContext = createContext<SocketContextType>({ socket: null, isConnected: false });

const SOCKET_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

export function SocketProvider({ children }: { children: ReactNode }) {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const queryClient = useQueryClient();

  useEffect(() => {
    const socketInstance = io(`${SOCKET_URL}/events`, {
      transports: ['websocket'],
      autoConnect: true,
    });

    socketInstance.on('connect', () => {
      setIsConnected(true);
      console.log('Socket.IO connected');
    });

    socketInstance.on('disconnect', () => {
      setIsConnected(false);
      console.log('Socket.IO disconnected');
    });

    // Order events
    socketInstance.on('order:created', (data: { orderNumber: string }) => {
      toast.success(`New order received: #${data.orderNumber}`);
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    socketInstance.on('order:updated', (data: { orderNumber: string; status: string }) => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    socketInstance.on('order:ready-for-fulfillment', (data: { orderNumber: string }) => {
      toast.success(`Order #${data.orderNumber} is ready for fulfillment!`);
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    socketInstance.on('order:cancelled', () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    // Print job events
    socketInstance.on('printjob:updated', () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    socketInstance.on('printjob:failed', (data: { error?: string }) => {
      toast.error(`Print job failed: ${data.error || 'Unknown error'}`);
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    // Notifications
    socketInstance.on('notification', (data: { type: string; message: string }) => {
      const toastFn = data.type === 'error' ? toast.error : data.type === 'warning' ? toast : toast.success;
      toastFn(data.message);
    });

    setSocket(socketInstance);

    return () => {
      socketInstance.disconnect();
    };
  }, [queryClient]);

  return (
    <SocketContext.Provider value={{ socket, isConnected }}>
      {children}
    </SocketContext.Provider>
  );
}

export function useSocket() {
  return useContext(SocketContext);
}

🔧 Feature F4.5: Activity Logs UI

Requirements Reference

  • FR-AD-005: Activity Logs

Implementation

1. Add Event Log List Endpoint (Backend)

Update apps/api/src/event-log/event-log.controller.ts:

import { Controller, Get, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { EventLogService } from './event-log.service';
import { EventLogQueryDto } from './dto/event-log-query.dto';

@ApiTags('Event Logs')
@Controller('api/v1/logs')
export class EventLogController {
  private readonly logger = new Logger(EventLogController.name);

  constructor(private readonly eventLogService: EventLogService) {}

  @Get()
  @ApiOperation({ summary: 'List event logs with filters' })
  @ApiQuery({ name: 'page', required: false, type: Number })
  @ApiQuery({ name: 'pageSize', required: false, type: Number })
  @ApiQuery({ name: 'severity', required: false, enum: ['INFO', 'WARNING', 'ERROR'] })
  @ApiQuery({ name: 'eventType', required: false, type: String })
  @ApiQuery({ name: 'orderId', required: false, type: String })
  @ApiQuery({ name: 'startDate', required: false, type: String })
  @ApiQuery({ name: 'endDate', required: false, type: String })
  @ApiResponse({ status: 200, description: 'Event logs retrieved successfully' })
  async getLogs(@Query() query: EventLogQueryDto) {
    this.logger.debug(`Fetching logs with query: ${JSON.stringify(query)}`);
    return this.eventLogService.findMany(query);
  }
}

2. Create Logs Hook (Frontend)

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

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';

interface LogsQuery {
  page?: number;
  pageSize?: number;
  severity?: 'INFO' | 'WARNING' | 'ERROR';
  eventType?: string;
  orderId?: string;
  startDate?: string;
  endDate?: string;
}

interface EventLog {
  id: string;
  orderId: string | null;
  printJobId: string | null;
  eventType: string;
  severity: string;
  message: string;
  metadata: Record<string, unknown> | null;
  createdAt: string;
}

interface LogsResponse {
  logs: EventLog[];
  total: number;
  page: number;
  pageSize: number;
}

export function useLogs(query: LogsQuery = {}) {
  return useQuery<LogsResponse>({
    queryKey: ['logs', query],
    queryFn: () => apiClient.getLogs(query),
  });
}

3. Create Logs Page (Frontend)

Create apps/web/src/pages/logs/index.tsx:

import { useState } from 'react';
import { format } from 'date-fns';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { useLogs } from '../../hooks/use-logs';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { SEVERITY_COLORS } from '../../lib/constants';

const SEVERITY_OPTIONS = [
  { value: '', label: 'All Severities' },
  { value: 'INFO', label: 'Info' },
  { value: 'WARNING', label: 'Warning' },
  { value: 'ERROR', label: 'Error' },
];

export function LogsPage() {
  const [page, setPage] = useState(1);
  const [severity, setSeverity] = useState<'INFO' | 'WARNING' | 'ERROR' | ''>('');
  const [eventType, setEventType] = useState('');

  const { data, isLoading, error } = useLogs({
    page,
    pageSize: 100,
    severity: severity || undefined,
    eventType: eventType || undefined,
  });

  const handleExport = () => {
    if (!data?.logs) return;

    const csv = [
      ['Timestamp', 'Severity', 'Event Type', 'Message', 'Order ID', 'Print Job ID'].join(','),
      ...data.logs.map((log) =>
        [
          format(new Date(log.createdAt), 'yyyy-MM-dd HH:mm:ss'),
          log.severity,
          log.eventType,
          `"${log.message.replace(/"/g, '""')}"`,
          log.orderId || '',
          log.printJobId || '',
        ].join(',')
      ),
    ].join('\n');

    const blob = new Blob([csv], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `activity-logs-${format(new Date(), 'yyyy-MM-dd')}.csv`;
    a.click();
    URL.revokeObjectURL(url);
  };

  if (error) {
    return (
      <div className="p-4 bg-red-50 text-red-700 rounded-lg">
        Error loading logs: {error.message}
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-gray-900">Activity Logs</h1>
        <Button variant="secondary" onClick={handleExport} disabled={!data?.logs?.length}>
          <ArrowDownTrayIcon className="w-4 h-4 mr-2" />
          Export CSV
        </Button>
      </div>

      {/* Filters */}
      <div className="flex flex-wrap gap-4 p-4 bg-white rounded-lg shadow">
        <select
          value={severity}
          onChange={(e) => setSeverity(e.target.value as 'INFO' | 'WARNING' | 'ERROR' | '')}
          className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
        >
          {SEVERITY_OPTIONS.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
        <input
          type="text"
          placeholder="Filter by event type..."
          value={eventType}
          onChange={(e) => setEventType(e.target.value)}
          className="flex-1 min-w-[200px] px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
        />
      </div>

      {/* Logs Table */}
      <div className="bg-white rounded-lg shadow overflow-hidden">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Timestamp
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Severity
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Event
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Message
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Related
              </th>
            </tr>
          </thead>
          <tbody className="bg-white divide-y divide-gray-200">
            {isLoading ? (
              <tr>
                <td colSpan={5} className="px-6 py-12 text-center text-gray-500">
                  Loading logs...
                </td>
              </tr>
            ) : data?.logs.length === 0 ? (
              <tr>
                <td colSpan={5} className="px-6 py-12 text-center text-gray-500">
                  No logs found
                </td>
              </tr>
            ) : (
              data?.logs.map((log) => (
                <tr key={log.id} className="hover:bg-gray-50">
                  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                    {format(new Date(log.createdAt), 'MMM d, HH:mm:ss')}
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <Badge variant={SEVERITY_COLORS[log.severity]?.badge || 'default'}>
                      {log.severity}
                    </Badge>
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap">
                    <span className="font-mono text-sm text-gray-700">{log.eventType}</span>
                  </td>
                  <td className="px-6 py-4 text-sm text-gray-900 max-w-md truncate">
                    {log.message}
                  </td>
                  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                    {log.orderId && (
                      <a href={`/orders/${log.orderId}`} className="text-blue-600 hover:underline">
                        Order
                      </a>
                    )}
                  </td>
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

🧪 Testing Requirements

Test Coverage Requirements

Per requirements.md (NFR-MA-002):

  • Unit Tests: > 80% coverage for all new services/components
  • Integration Tests: All API endpoints tested
  • E2E Tests: Critical user flows covered
  • Acceptance Tests: New Gherkin scenarios for Phase 4 functionality

Unit Test Scenarios Required

Category Scenario Priority
Orders UI Order list renders correctly Critical
Orders UI Order filters work correctly High
Orders UI Order detail shows all information Critical
Orders UI Manual actions (retry, cancel) work Critical
Mappings UI Mapping list renders correctly High
Mappings UI Create mapping form validates input High
Mappings UI Toggle mapping status works Medium
Real-Time Socket connection established High
Real-Time Events update UI correctly High
Logs UI Log list renders with filters Medium
Logs UI Export to CSV works Low

Acceptance Test Requirements (Playwright + Gherkin)

New Feature Files to Create

Create apps/acceptance-tests/src/features/dashboard.feature:

@smoke @web
Feature: Dashboard MVP
  As an operator
  I want to view and manage orders through a web interface
  So that I can monitor and handle exceptions

  Background:
    Given I am logged in to the dashboard

  @critical
  Scenario: Dashboard overview loads
    When I navigate to the dashboard
    Then I should see the dashboard overview
    And I should see order statistics

  Scenario: View order list
    When I navigate to the orders page
    Then I should see the orders table
    And I should see filter options

  Scenario: Filter orders by status
    Given I am on the orders page
    When I select status filter "PENDING"
    Then I should see only pending orders

Create apps/acceptance-tests/src/features/mappings.feature:

@web
Feature: Product Mapping Management
  As an administrator
  I want to manage product mappings
  So that orders can be automatically processed

  Background:
    Given I am logged in to the dashboard

  @critical
  Scenario: View product mappings list
    When I navigate to the mappings page
    Then I should see the mappings table
    And I should see the "Add Mapping" button

  Scenario: Create new mapping
    Given I am on the mappings page
    When I click "Add Mapping"
    And I fill in the mapping form
    And I submit the form
    Then I should see the new mapping in the list

✅ Validation Checklist

Dashboard Foundation (F4.1)

  • React app bootstrapped with TanStack Query
  • Tailwind CSS configured and working
  • Router configured with all routes
  • Authentication context and login flow working
  • Sidebar navigation functional
  • UI components library created (Button, Badge, etc.)

Order Management UI (F4.2)

  • Order list page with filtering and search
  • Order detail page with full information
  • Manual actions working (retry, cancel, force fulfill)
  • Print job status displayed
  • Pagination working
  • Real-time updates reflected

Product Mapping UI (F4.3)

  • Mapping list page with search
  • Create/edit mapping form working
  • Assembly parts editor functional
  • Activate/deactivate toggle working
  • Delete mapping working

Real-Time Updates (F4.4)

  • WebSocket gateway created (backend)
  • Socket.IO client connected (frontend)
  • Order events reflected in UI
  • Print job events reflected in UI
  • Toast notifications displayed
  • Connection status visible

Activity Logs UI (F4.5)

  • Logs list page with filtering
  • Severity filtering working
  • Event type filtering working
  • Export to CSV functional
  • Links to related orders working

Testing

  • Unit tests > 80% coverage for new code
  • Acceptance tests added for dashboard features
  • All acceptance tests passing against staging

🚫 Constraints and Rules

MUST DO

  • Use TanStack Query for all server state management
  • Use Tailwind CSS for all styling (no custom CSS unless necessary)
  • Follow existing code patterns from apps/web/src/
  • Use typed API client from libs/api-client
  • Add Swagger documentation for any new backend endpoints
  • Emit events for real-time updates on all state changes
  • Handle loading and error states in all components
  • Make all tables sortable and filterable
  • Use existing authentication via API key for admin actions
  • Write unit tests for all new components
  • Add acceptance tests (Playwright + Gherkin) for all user flows
  • Update ALL documentation (see Documentation Updates section)

MUST NOT

  • Use Redux or other state management (use TanStack Query + React Context)
  • Use CSS-in-JS solutions (use Tailwind)
  • Make API calls directly in components (use hooks)
  • Skip error handling
  • Skip loading states
  • Break existing API functionality
  • Store sensitive data in localStorage (except API key)
  • Skip writing tests
  • Leave documentation incomplete

🎬 Execution Order

Implementation

  1. Install frontend dependencies (TanStack Query, Headless UI, Heroicons, etc.)
  2. Create UI component library (Button, Badge, Card, Table, etc.)
  3. Set up TanStack Query provider and configuration
  4. Create authentication context and login flow
  5. Update router with all new routes
  6. Update sidebar with full navigation
  7. Extend API client with all required endpoints
  8. Create orders hooks and pages
  9. Create mappings hooks and pages
  10. Create WebSocket gateway (backend)
  11. Create Socket context (frontend)
  12. Create logs hooks and page
  13. Update dashboard overview with statistics

Testing

  1. Write unit tests for all new components (> 80% coverage)
  2. Add acceptance tests for dashboard features
  3. Test real-time updates with multiple browser tabs
  4. Test mobile responsiveness

Documentation

  1. Update Swagger documentation — Add @Api* decorators to any new endpoints
  2. Update README.md — Add dashboard section with screenshots
  3. Update docs/implementation-plan.md — Mark Phase 4 features as complete
  4. Update docs/requirements.md — Mark FR-AD-001 through FR-AD-006 as implemented
  5. Update docs/architecture/ADR.md — Add any new architectural decisions
  6. Update C4 diagrams — Add dashboard components if needed

Validation

  1. Run full validation checklist
  2. Verify acceptance tests pass in pipeline
  3. Confirm all documentation is complete

📊 Expected Output

When Phase 4 is complete:

Verification Commands

# Build all projects
pnpm nx build api
pnpm nx build web

# Run tests
pnpm nx test api
pnpm nx test web

# Start development servers
pnpm dev

# Open dashboard at http://localhost:4200
# API at http://localhost:3000

Dashboard Features Verification

  1. Login with API key
  2. View dashboard with order statistics
  3. Navigate to orders — see list with filters
  4. Click on order — see detail with print jobs
  5. Retry failed job — action completes
  6. Navigate to mappings — see list
  7. Create new mapping — form works
  8. View activity logs — filters work
  9. Export logs — CSV downloads
  10. Real-time updates — open two tabs, create order in one, see update in other

📝 Documentation Updates

CRITICAL: All documentation must be updated to reflect Phase 4 completion.

README.md Updates Required

Add sections for:

  1. Dashboard Access — How to access and authenticate
  2. Features Overview — Screenshots and feature descriptions
  3. Real-Time Updates — WebSocket functionality
  4. Configuration — Frontend environment variables

docs/implementation-plan.md Updates Required

Update the implementation plan to mark Phase 4 as complete:

  • Mark F4.1 (Dashboard Foundation) as ✅ Completed
  • Mark F4.2 (Order Management UI) as ✅ Completed
  • Mark F4.3 (Product Mapping UI) as ✅ Completed
  • Mark F4.4 (Real-Time Updates) as ✅ Completed
  • Mark F4.5 (Activity Logs UI) as ✅ Completed
  • Update Phase 4 Exit Criteria with checkmarks
  • Add implementation notes and component paths
  • Update revision history with completion date

docs/requirements.md Updates Required

Update requirements document to mark Phase 4 requirements as implemented:

  • Mark FR-AD-001 (Order Queue View) as ✅ Implemented
  • Mark FR-AD-002 (Order Detail View) as ✅ Implemented
  • Mark FR-AD-003 (Product Mapping Management) as ✅ Implemented
  • Mark FR-AD-004 (System Configuration) as ✅ Implemented (if applicable)
  • Mark FR-AD-005 (Activity Logs) as ✅ Implemented
  • Mark FR-AD-006 (Manual Order Processing) as ✅ Implemented
  • Update revision history

docs/architecture/ADR.md Updates (If Applicable)

Consider adding ADRs for:

  • ADR-025: TanStack Query for Server State Management
  • ADR-026: WebSocket Real-Time Updates Architecture
  • ADR-027: Dashboard Authentication Strategy

docs/architecture/C4 Diagrams Updates (PlantUML)

Update the following PlantUML diagrams to reflect Phase 4 changes:

Diagram Path Updates Required
Web Components docs/architecture/C4_Component_Web.puml Add TanStack Query, Socket.IO client, new pages (Orders, Mappings, Logs), UI components
Container docs/architecture/C4_Container.puml Update web app description to include dashboard capabilities
Component docs/architecture/C4_Component.puml Add WebSocket Gateway component to backend

Checklist:

  • C4_Component_Web.puml — Add: TanStack Query provider, Socket context, Auth context, Pages (OrdersPage, MappingsPage, LogsPage, Dashboard), UI component library
  • C4_Container.puml — Update web app description: "React 19 dashboard with order management, product mapping UI, real-time updates via Socket.IO"
  • C4_Component.puml — Add: EventsGateway (WebSocket) component connected to EventEmitter2

🔗 Phase 4 Exit Criteria

From implementation-plan.md:

  • Dashboard accessible and authenticated
  • Order management fully functional
  • Product mappings configurable via UI
  • Real-time updates working
  • Activity logs available

Additional Exit Criteria

  • Unit tests > 80% coverage for all new code
  • Acceptance tests added for Phase 4 functionality
  • All acceptance tests passing against staging
  • README.md updated with dashboard section
  • docs/implementation-plan.md updated — Phase 4 marked as complete
  • docs/requirements.md updated — Phase 4 requirements marked as implemented
  • All new API endpoints documented in Swagger

🔮 Phase 5 Preview

Phase 5 (Shipping Integration) will build on Phase 4:

  • Sendcloud API client for shipping labels
  • Automated label generation on order completion
  • Tracking information sync to Shopify
  • Shipping management in dashboard UI

The dashboard foundation established in Phase 4 will be extended with shipping-specific views and functionality.


END OF PROMPT


This prompt builds on the Phase 3 foundation. The AI should implement all Phase 4 dashboard features while maintaining the established code style, architectural patterns, and testing standards. Phase 4 completes the administrative interface enabling operators to monitor and manage the entire order processing pipeline.