Skip to content

[Phase 1] Order Dashboard UI #25

@syed-reza98

Description

@syed-reza98

Priority: P0

Phase: 1
Epic: #23 Order Management
Estimate: 2 days
Type: Story

Context

The Order Dashboard UI provides vendors with a comprehensive interface to manage their store orders, view order details, update fulfillment status, process refunds, and track order lifecycle metrics. This interface must handle 100+ orders efficiently with real-time status updates and support multi-tenant data isolation.

According to the implementation research, this UI component connects directly to the OrderService and supports the complete order lifecycle workflow: PENDING → PROCESSING → SHIPPED → DELIVERED, with alternate paths for CANCELLED and REFUNDED states.

Acceptance Criteria

  • OrderList Component: Display 100+ orders with DataTable (shadcn-ui) including search, filters (status, payment, date range), sort, pagination (50/page)
  • Order Detail View: Show order metadata (customer, total, items), payment attempts table, fulfillment status timeline, line items with quantities/prices/images
  • Status Update Controls: Update order status with validation (can't skip states), add tracking number for SHIPPED status, prevent status regression (DELIVERED → PROCESSING)
  • Fulfillment Management: Create partial fulfillments (select subset of items), mark items shipped individually, update tracking information, mark fulfilled orders complete
  • Refund Processing: Issue full/partial refunds with amount validation, select specific items for refund, show refundable balance (order total - refunded amount), integrate with Stripe refund API
  • Performance: p95 list render < 300ms for 100 orders, p95 detail page load < 250ms, optimistic UI updates for status changes
  • Mobile Responsive: Tablet-friendly layout (iPad 768px+), collapsible filters, stacked detail sections
  • Multi-Tenancy: All queries filtered by storeId, no cross-store data leakage, enforce row-level security in middleware
  • Real-Time Updates: Use React Query with 30s polling for order status changes, show toast notifications for new orders
  • Error Handling: Display clear error messages for failed refunds/status updates, retry mechanism for network failures

Technical Implementation

1. OrderList Component (DataTable Integration)

// src/app/dashboard/orders/page.tsx
"use client";

import { useState } from "react";
import { useSession } from "next-auth/react";
import { DataTable } from "@/components/data-table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Eye, FileText, Ban } from "lucide-react";
import Link from "next/link";
import { OrderStatus } from "@prisma/client";

interface Order {
  id: string;
  orderNumber: string;
  customerName: string;
  customerEmail: string;
  totalAmount: number;
  status: OrderStatus;
  paymentStatus: string;
  createdAt: Date;
  itemCount: number;
}

const statusColors: Record<OrderStatus, string> = {
  PENDING: "bg-yellow-500",
  PROCESSING: "bg-blue-500",
  SHIPPED: "bg-purple-500",
  DELIVERED: "bg-green-500",
  CANCELLED: "bg-red-500",
  REFUNDED: "bg-gray-500",
};

const columns = [
  {
    accessorKey: "orderNumber",
    header: "Order #",
    cell: ({ row }: any) => (
      <Link href={`/dashboard/orders/${row.original.id}`} className="font-medium hover:underline">
        {row.getValue("orderNumber")}
      </Link>
    ),
  },
  {
    accessorKey: "customerName",
    header: "Customer",
    cell: ({ row }: any) => (
      <div>
        <div className="font-medium">{row.getValue("customerName")}</div>
        <div className="text-sm text-muted-foreground">{row.original.customerEmail}</div>
      </div>
    ),
  },
  {
    accessorKey: "itemCount",
    header: "Items",
  },
  {
    accessorKey: "totalAmount",
    header: "Total",
    cell: ({ row }: any) => `৳${row.getValue("totalAmount").toFixed(2)}`,
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }: any) => {
      const status = row.getValue("status") as OrderStatus;
      return (
        <Badge className={`${statusColors[status]} text-white`}>
          {status}
        </Badge>
      );
    },
  },
  {
    accessorKey: "paymentStatus",
    header: "Payment",
    cell: ({ row }: any) => (
      <Badge variant={row.getValue("paymentStatus") === "PAID" ? "default" : "secondary"}>
        {row.getValue("paymentStatus")}
      </Badge>
    ),
  },
  {
    accessorKey: "createdAt",
    header: "Date",
    cell: ({ row }: any) => new Date(row.getValue("createdAt")).toLocaleDateString("en-US", {
      month: "short",
      day: "numeric",
      year: "numeric"
    }),
  },
  {
    id: "actions",
    cell: ({ row }: any) => {
      const order = row.original;
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" className="h-8 w-8 p-0">
              <MoreHorizontal className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem asChild>
              <Link href={`/dashboard/orders/${order.id}`}>
                <Eye className="mr-2 h-4 w-4" />
                View Details
              </Link>
            </DropdownMenuItem>
            <DropdownMenuItem asChild>
              <Link href={`/dashboard/orders/${order.id}/invoice`}>
                <FileText className="mr-2 h-4 w-4" />
                View Invoice
              </Link>
            </DropdownMenuItem>
            {order.status !== "CANCELLED" && order.status !== "REFUNDED" && (
              <DropdownMenuItem className="text-red-600">
                <Ban className="mr-2 h-4 w-4" />
                Cancel Order
              </DropdownMenuItem>
            )}
          </DropdownMenuContent>
        </DropdownMenu>
      );
    },
  },
];

export default function OrdersPage() {
  const { data: session } = useSession();
  const [orders, setOrders] = useState<Order[]>([]);
  const [loading, setLoading] = useState(true);
  const [filters, setFilters] = useState({
    status: "ALL",
    paymentStatus: "ALL",
    dateFrom: "",
    dateTo: "",
    search: "",
  });

  // Fetch orders with React Query or SWR
  useEffect(() => {
    async function fetchOrders() {
      try {
        const queryParams = new URLSearchParams({
          ...filters,
          page: "1",
          limit: "50",
        });
        const response = await fetch(`/api/orders?${queryParams}`);
        const data = await response.json();
        setOrders(data.orders);
      } catch (error) {
        console.error("Failed to fetch orders:", error);
      } finally {
        setLoading(false);
      }
    }
    fetchOrders();
  }, [filters]);

  return (
    <div className="space-y-4">
      <div className="flex justify-between items-center">
        <h1 className="text-3xl font-bold">Orders</h1>
      </div>
      
      {/* Filters UI */}
      <div className="flex gap-4 flex-wrap">
        <Select value={filters.status} onValueChange={(v) => setFilters({...filters, status: v})}>
          <SelectTrigger className="w-[180px]">
            <SelectValue placeholder="Filter by status" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="ALL">All Statuses</SelectItem>
            <SelectItem value="PENDING">Pending</SelectItem>
            <SelectItem value="PROCESSING">Processing</SelectItem>
            <SelectItem value="SHIPPED">Shipped</SelectItem>
            <SelectItem value="DELIVERED">Delivered</SelectItem>
          </SelectContent>
        </Select>
        {/* Add more filters: payment status, date range, search */}
      </div>

      <DataTable columns={columns} data={orders} loading={loading} />
    </div>
  );
}

2. Order Detail Page (Status Timeline + Fulfillment)

// src/app/dashboard/orders/[id]/page.tsx
"use client";

import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Truck, Package, CheckCircle, Clock, AlertCircle } from "lucide-react";
import { toast } from "sonner";

interface OrderDetail {
  id: string;
  orderNumber: string;
  status: string;
  totalAmount: number;
  customer: {
    name: string;
    email: string;
    phone: string;
  };
  shippingAddress: any;
  items: Array<{
    id: string;
    productName: string;
    variantName: string;
    quantity: number;
    price: number;
    imageUrl: string;
  }>;
  paymentAttempts: Array<{
    id: string;
    amount: number;
    status: string;
    createdAt: Date;
  }>;
  fulfillments: Array<{
    id: string;
    trackingNumber: string;
    status: string;
    createdAt: Date;
  }>;
  refunds: Array<{
    id: string;
    amount: number;
    status: string;
    createdAt: Date;
  }>;
  refundableBalance: number;
  createdAt: Date;
}

export default function OrderDetailPage() {
  const params = useParams();
  const [order, setOrder] = useState<OrderDetail | null>(null);
  const [loading, setLoading] = useState(true);
  const [trackingNumber, setTrackingNumber] = useState("");
  const [refundAmount, setRefundAmount] = useState("");
  const [refundReason, setRefundReason] = useState("");

  useEffect(() => {
    async function fetchOrder() {
      try {
        const response = await fetch(`/api/orders/${params.id}`);
        const data = await response.json();
        setOrder(data);
      } catch (error) {
        toast.error("Failed to load order");
      } finally {
        setLoading(false);
      }
    }
    fetchOrder();
  }, [params.id]);

  const updateOrderStatus = async (newStatus: string) => {
    try {
      const response = await fetch(`/api/orders/${params.id}/status`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ 
          status: newStatus,
          ...(newStatus === "SHIPPED" && { trackingNumber })
        }),
      });
      
      if (!response.ok) throw new Error("Failed to update status");
      
      const updatedOrder = await response.json();
      setOrder(updatedOrder);
      toast.success(`Order status updated to ${newStatus}`);
    } catch (error) {
      toast.error("Failed to update order status");
    }
  };

  const processRefund = async () => {
    try {
      const amount = parseFloat(refundAmount);
      if (amount <= 0 || amount > (order?.refundableBalance || 0)) {
        toast.error("Invalid refund amount");
        return;
      }

      const response = await fetch(`/api/orders/${params.id}/refund`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ amount, reason: refundReason }),
      });

      if (!response.ok) throw new Error("Refund failed");

      toast.success(`Refund of ৳${amount.toFixed(2)} initiated`);
      // Refresh order data
      window.location.reload();
    } catch (error) {
      toast.error("Refund processing failed");
    }
  };

  if (loading) return <div>Loading...</div>;
  if (!order) return <div>Order not found</div>;

  return (
    <div className="space-y-6">
      <div className="flex justify-between items-center">
        <h1 className="text-3xl font-bold">Order {order.orderNumber}</h1>
        <Badge className={statusColors[order.status as OrderStatus]}>
          {order.status}
        </Badge>
      </div>

      {/* Status Timeline */}
      <Card>
        <CardHeader>
          <CardTitle>Order Timeline</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="flex items-center justify-between">
            {["PENDING", "PROCESSING", "SHIPPED", "DELIVERED"].map((status, idx) => (
              <div key={status} className="flex flex-col items-center">
                <div className={`rounded-full p-3 ${
                  order.status === status ? "bg-primary" : "bg-gray-200"
                }`}>
                  {status === "PENDING" && <Clock className="h-5 w-5" />}
                  {status === "PROCESSING" && <Package className="h-5 w-5" />}
                  {status === "SHIPPED" && <Truck className="h-5 w-5" />}
                  {status === "DELIVERED" && <CheckCircle className="h-5 w-5" />}
                </div>
                <span className="mt-2 text-sm">{status}</span>
              </div>
            ))}
          </div>
        </CardContent>
      </Card>

      {/* Order Items */}
      <Card>
        <CardHeader>
          <CardTitle>Order Items</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="space-y-4">
            {order.items.map((item) => (
              <div key={item.id} className="flex items-center gap-4">
                <img src={item.imageUrl} alt={item.productName} className="w-16 h-16 object-cover rounded" />
                <div className="flex-1">
                  <h4 className="font-medium">{item.productName}</h4>
                  {item.variantName && <p className="text-sm text-muted-foreground">{item.variantName}</p>}
                  <p className="text-sm">Qty: {item.quantity}</p>
                </div>
                <div className="text-right">
                  <p className="font-medium">{(item.price * item.quantity).toFixed(2)}</p>
                </div>
              </div>
            ))}
            <Separator />
            <div className="flex justify-between text-lg font-bold">
              <span>Total</span>
              <span>{order.totalAmount.toFixed(2)}</span>
            </div>
          </div>
        </CardContent>
      </Card>

      {/* Status Actions */}
      <Card>
        <CardHeader>
          <CardTitle>Actions</CardTitle>
        </CardHeader>
        <CardContent className="flex gap-4">
          {order.status === "PENDING" && (
            <Button onClick={() => updateOrderStatus("PROCESSING")}>
              Mark as Processing
            </Button>
          )}
          {order.status === "PROCESSING" && (
            <Dialog>
              <DialogTrigger asChild>
                <Button>Mark as Shipped</Button>
              </DialogTrigger>
              <DialogContent>
                <DialogHeader>
                  <DialogTitle>Ship Order</DialogTitle>
                  <DialogDescription>Enter tracking number</DialogDescription>
                </DialogHeader>
                <div className="space-y-4">
                  <div>
                    <Label htmlFor="tracking">Tracking Number</Label>
                    <Input
                      id="tracking"
                      value={trackingNumber}
                      onChange={(e) => setTrackingNumber(e.target.value)}
                      placeholder="TRK-123456"
                    />
                  </div>
                  <Button onClick={() => updateOrderStatus("SHIPPED")} className="w-full">
                    Confirm Shipment
                  </Button>
                </div>
              </DialogContent>
            </Dialog>
          )}
          {order.status === "SHIPPED" && (
            <Button onClick={() => updateOrderStatus("DELIVERED")}>
              Mark as Delivered
            </Button>
          )}
          {order.refundableBalance > 0 && (
            <Dialog>
              <DialogTrigger asChild>
                <Button variant="destructive">Issue Refund</Button>
              </DialogTrigger>
              <DialogContent>
                <DialogHeader>
                  <DialogTitle>Process Refund</DialogTitle>
                  <DialogDescription>
                    Refundable balance: ৳{order.refundableBalance.toFixed(2)}
                  </DialogDescription>
                </DialogHeader>
                <div className="space-y-4">
                  <div>
                    <Label htmlFor="amount">Refund Amount (৳)</Label>
                    <Input
                      id="amount"
                      type="number"
                      value={refundAmount}
                      onChange={(e) => setRefundAmount(e.target.value)}
                      max={order.refundableBalance}
                    />
                  </div>
                  <div>
                    <Label htmlFor="reason">Reason</Label>
                    <Input
                      id="reason"
                      value={refundReason}
                      onChange={(e) => setRefundReason(e.target.value)}
                      placeholder="Customer request"
                    />
                  </div>
                  <Button onClick={processRefund} className="w-full">
                    Process Refund
                  </Button>
                </div>
              </DialogContent>
            </Dialog>
          )}
        </CardContent>
      </Card>

      {/* Payment History */}
      {order.paymentAttempts.length > 0 && (
        <Card>
          <CardHeader>
            <CardTitle>Payment History</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="space-y-2">
              {order.paymentAttempts.map((attempt) => (
                <div key={attempt.id} className="flex justify-between">
                  <span>{attempt.amount.toFixed(2)}</span>
                  <Badge>{attempt.status}</Badge>
                  <span className="text-sm text-muted-foreground">
                    {new Date(attempt.createdAt).toLocaleString()}
                  </span>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      )}

      {/* Refund History */}
      {order.refunds.length > 0 && (
        <Card>
          <CardHeader>
            <CardTitle>Refunds</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="space-y-2">
              {order.refunds.map((refund) => (
                <div key={refund.id} className="flex justify-between">
                  <span>{refund.amount.toFixed(2)}</span>
                  <Badge variant="destructive">{refund.status}</Badge>
                  <span className="text-sm text-muted-foreground">
                    {new Date(refund.createdAt).toLocaleString()}
                  </span>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      )}
    </div>
  );
}

3. API Route for Status Updates

// src/app/api/orders/[id]/status/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";

const UpdateStatusSchema = z.object({
  status: z.enum(["PENDING", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"]),
  trackingNumber: z.string().optional(),
});

export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const body = await request.json();
    const { status, trackingNumber } = UpdateStatusSchema.parse(body);

    // Fetch order with store validation
    const order = await prisma.order.findFirst({
      where: {
        id: params.id,
        store: {
          memberships: {
            some: { userId: session.user.id, role: { in: ["OWNER", "ADMIN"] } },
          },
        },
      },
      include: { store: true },
    });

    if (!order) {
      return NextResponse.json({ error: "Order not found" }, { status: 404 });
    }

    // Validate status transition
    const validTransitions: Record<string, string[]> = {
      PENDING: ["PROCESSING", "CANCELLED"],
      PROCESSING: ["SHIPPED", "CANCELLED"],
      SHIPPED: ["DELIVERED"],
      DELIVERED: [], // Terminal state
      CANCELLED: [], // Terminal state
    };

    if (!validTransitions[order.status]?.includes(status)) {
      return NextResponse.json(
        { error: `Cannot transition from ${order.status} to ${status}` },
        { status: 400 }
      );
    }

    // Update order
    const updatedOrder = await prisma.order.update({
      where: { id: params.id },
      data: {
        status,
        ...(status === "SHIPPED" && trackingNumber && {
          fulfillments: {
            create: {
              trackingNumber,
              status: "SHIPPED",
              shippedAt: new Date(),
            },
          },
        }),
      },
      include: {
        items: {
          include: {
            product: { select: { name: true, images: true } },
            variant: { select: { name: true } },
          },
        },
        customer: true,
        paymentAttempts: true,
        fulfillments: true,
        refunds: true,
      },
    });

    // Create audit log
    await prisma.auditLog.create({
      data: {
        userId: session.user.id,
        storeId: order.storeId,
        action: "ORDER_STATUS_UPDATE",
        entityType: "Order",
        entityId: order.id,
        changes: { from: order.status, to: status },
      },
    });

    return NextResponse.json(updatedOrder);
  } catch (error) {
    console.error("Order status update error:", error);
    return NextResponse.json(
      { error: "Failed to update order status" },
      { status: 500 }
    );
  }
}

Dependencies

Blocks:

  • None (frontend component)

Blocked By:

References

  • Implementation Plan: docs/research/implementation_plan.md (Order Lifecycle Integration)
  • Feature Roadmap: docs/research/feature_roadmap_user_stories.md (Order management stories)
  • Existing OrderService: src/lib/services/order.service.ts (created in [Phase 1] Order Processing API #24)
  • shadcn-ui DataTable: src/components/data-table.tsx
  • Gap Analysis: docs/research/codebase_feature_gap_analysis.md (Order lifecycle completeness)

Testing Checklist

  • Load Performance: Render 100 orders in <300ms (p95)
  • Status Updates: Successfully update PENDING → PROCESSING → SHIPPED → DELIVERED flow
  • Refund Processing: Issue partial refund ($50 on $100 order), verify refundable balance updates
  • Multi-Tenancy: User A cannot view/edit User B's orders (different stores)
  • Validation: Cannot mark PENDING order as DELIVERED (skip PROCESSING)
  • Tracking Numbers: SHIPPED status requires tracking number, stored in fulfillments table
  • Payment History: Display multiple payment attempts (failed → successful retry)
  • Mobile Responsive: Order list and detail view work on iPad (768px)
  • Refund Limits: Cannot refund more than refundable balance
  • Status Regression: Cannot move DELIVERED order back to PROCESSING
  • Filters: Apply status filter (SHIPPED only), date range filter (last 30 days), search by order number
  • Real-Time Updates: New order appears in list within 30 seconds (polling)
  • Audit Trail: All status updates logged in AuditLog table with userId and timestamp

Current Status

❌ Not Started (Depends on #24 Order Processing API completion)


Research Insights Applied:

  • Order lifecycle fidelity from Gap Analysis (PaymentAttempt, Refund, Fulfillment models)
  • Status transition validation from Implementation Plan (prevent invalid state changes)
  • Multi-tenant filtering enforced via Repository pattern (membership-based access control)
  • Real-time updates via polling aligned with Cloud-Native observability strategy
  • Refundable balance calculation supporting partial refunds (financial integrity requirement)

Metadata

Metadata

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions