-
Notifications
You must be signed in to change notification settings - Fork 0
Labels
Phase 1Priority 1enhancementNew feature or requestNew feature or requestproductionChanges for Production Environment and ConfigurationChanges for Production Environment and Configurationtype:story
Description
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:
- [Phase 1] Order Processing API #24 (Order Processing API) - Requires order creation and retrieval APIs
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)
Copilot
Metadata
Metadata
Assignees
Labels
Phase 1Priority 1enhancementNew feature or requestNew feature or requestproductionChanges for Production Environment and ConfigurationChanges for Production Environment and Configurationtype:story
Type
Projects
Status
Done