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-fulfillmentevent -
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.pumlDashboard React component structure Container View docs/architecture/C4_Container.pumlSystem containers and interactions Component View docs/architecture/C4_Component.pumlBackend component architecture Order State docs/architecture/C4_Code_State_Order.pumlOrder status state machine Print Job State docs/architecture/C4_Code_State_PrintJob.pumlPrint job status transitions Domain Model docs/architecture/C4_Code_DomainModel.pumlEntity relationships Fulfillment Flow docs/architecture/C4_Seq_05_Fulfillment.pumlFulfillment 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.pumlMain dashboard layout with sidebar Dashboard 02-dashboard-overview.pumlStats cards and recent orders Orders List 03-orders-list.pumlOrders table with filters Order Detail 04-order-detail.pumlOrder view with print jobs Mappings List 05-mappings-list.pumlProduct mappings table Mapping Form 06-mapping-form.pumlCreate/edit mapping form Logs List 07-logs-list.pumlActivity logs with export Login 08-login.pumlAPI 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¶
- Install frontend dependencies (TanStack Query, Headless UI, Heroicons, etc.)
- Create UI component library (Button, Badge, Card, Table, etc.)
- Set up TanStack Query provider and configuration
- Create authentication context and login flow
- Update router with all new routes
- Update sidebar with full navigation
- Extend API client with all required endpoints
- Create orders hooks and pages
- Create mappings hooks and pages
- Create WebSocket gateway (backend)
- Create Socket context (frontend)
- Create logs hooks and page
- Update dashboard overview with statistics
Testing¶
- Write unit tests for all new components (> 80% coverage)
- Add acceptance tests for dashboard features
- Test real-time updates with multiple browser tabs
- Test mobile responsiveness
Documentation¶
- Update Swagger documentation — Add
@Api*decorators to any new endpoints - Update README.md — Add dashboard section with screenshots
- Update docs/implementation-plan.md — Mark Phase 4 features as complete
- Update docs/requirements.md — Mark FR-AD-001 through FR-AD-006 as implemented
- Update docs/architecture/ADR.md — Add any new architectural decisions
- Update C4 diagrams — Add dashboard components if needed
Validation¶
- Run full validation checklist
- Verify acceptance tests pass in pipeline
- 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¶
- Login with API key
- View dashboard with order statistics
- Navigate to orders — see list with filters
- Click on order — see detail with print jobs
- Retry failed job — action completes
- Navigate to mappings — see list
- Create new mapping — form works
- View activity logs — filters work
- Export logs — CSV downloads
- 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:
- Dashboard Access — How to access and authenticate
- Features Overview — Screenshots and feature descriptions
- Real-Time Updates — WebSocket functionality
- 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.