diff --git a/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx b/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx new file mode 100644 index 0000000..005f327 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx @@ -0,0 +1,143 @@ +"use client" + +import { useQuery } from "convex/react" +import { useParams } from "next/navigation" +import { api } from "../../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../../convex/_generated/dataModel" +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { OrderPageHeader } from "@/components/orders/detail/OrderPageHeader" +import { OrderItemsCard } from "@/components/orders/detail/OrderItemsCard" +import { OrderFinancialsCard } from "@/components/orders/detail/OrderFinancialsCard" +import { CustomerCard } from "@/components/orders/detail/CustomerCard" +import { ShippingAddressCard } from "@/components/orders/detail/ShippingAddressCard" +import { FulfilmentCard } from "@/components/orders/detail/FulfilmentCard" +import { OrderTimelineCard } from "@/components/orders/detail/OrderTimelineCard" +import { OrderActionsBar } from "@/components/orders/detail/OrderActionsBar" + +// ─── Per-card skeletons (no layout shift on load) ───────────────────────────── + +function HeaderSkeleton() { + return ( +
+ +
+ + + + +
+
+ ) +} + +function CardSkeleton({ rows = 3 }: { rows?: number }) { + return ( + + + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + + + ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function OrderDetailPage() { + const params = useParams() + const orderId = params.id as Id<"orders"> + + const order = useQuery(api.orders.getById, { id: orderId }) + // Timeline query is wired here for Phase 5 — passed to OrderTimelineCard + const events = useQuery(api.orders.getTimeline, { orderId }) + + // ── Loading state ─────────────────────────────────────────────────────────── + if (order === undefined) { + return ( +
+ +
+ {/* Left column */} +
+ + + +
+ {/* Right column */} +
+ + + +
+
+
+ ) + } + + return ( +
+ {/* Header — back link, order#, date, status badges */} + + + + + {/* Two-column layout */} +
+ {/* ── Left column ──────────────────────────────────────────────────── */} +
+ + + +
+ + {/* ── Right column ─────────────────────────────────────────────────── */} +
+ + + +
+
+
+ ) +} diff --git a/apps/admin/src/app/(dashboard)/orders/page.tsx b/apps/admin/src/app/(dashboard)/orders/page.tsx index 7eb25c6..7743863 100644 --- a/apps/admin/src/app/(dashboard)/orders/page.tsx +++ b/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -1,5 +1,267 @@ -import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; +"use client" + +import { useState, useMemo } from "react" +import { usePaginatedQuery } from "convex/react" +import { useRouter } from "next/navigation" +import { api } from "../../../../../../convex/_generated/api" +import { formatPrice } from "@repo/utils" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Search01Icon, + Cancel01Icon, + ArrowRight01Icon, +} from "@hugeicons/core-free-icons" +import { OrderStatusBadge } from "@/components/orders/shared/OrderStatusBadge" +import { OrderPaymentBadge } from "@/components/orders/shared/OrderPaymentBadge" +import { + ORDER_STATUS_CONFIG, + type OrderStatus, +} from "@/components/orders/shared/statusConfig" + +// ─── Status filter options ──────────────────────────────────────────────────── + +const STATUS_OPTIONS = [ + { value: "all", label: "All statuses" }, + ...Object.entries(ORDER_STATUS_CONFIG).map(([value, config]) => ({ + value, + label: config.label, + })), +] + +// ─── Skeleton ───────────────────────────────────────────────────────────────── + +function TableSkeleton() { + return ( + <> + {Array.from({ length: 10 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + + + + ))} + + ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── export default function OrdersPage() { - return ; + const router = useRouter() + const [searchInput, setSearchInput] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + + const { + results, + status, + loadMore, + } = usePaginatedQuery( + api.orders.listAll, + statusFilter !== "all" ? { status: statusFilter } : {}, + { initialNumItems: 25 }, + ) + + const isLoading = status === "LoadingFirstPage" + + // Client-side search over loaded results. + // NOTE: filtered to the currently loaded page only — no server-side search + // index exists yet. Add api.orders.searchByOrderNumberOrEmail when needed. + const orders = useMemo(() => { + const q = searchInput.trim().toLowerCase() + if (!q) return results + return results.filter( + (o) => + o.orderNumber.toLowerCase().includes(q) || + o.email.toLowerCase().includes(q), + ) + }, [results, searchInput]) + + const isSearching = searchInput.trim().length > 0 + + function emptyMessage() { + if (isSearching) return `No orders match "${searchInput.trim()}".` + if (statusFilter !== "all") { + const label = + ORDER_STATUS_CONFIG[statusFilter as OrderStatus]?.label ?? statusFilter + return `No orders with status "${label}".` + } + return "No orders yet." + } + + return ( +
+ {/* Header */} +
+

Orders

+
+ + {/* Toolbar */} +
+
+ + setSearchInput(e.target.value)} + className="pl-8 pr-8" + /> + {searchInput && ( + + )} +
+ + +
+ + {/* Table */} +
+ + + + Order # + Customer + Date + Status + Payment + + Total + + + + + + {isLoading ? ( + + ) : orders.length === 0 ? ( + + + {emptyMessage()} + + + ) : ( + orders.map((order) => ( + router.push(`/orders/${order._id}`)} + > + + {order.orderNumber} + + + {order.email} + + + {new Date(order.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + + + + + + + + + {formatPrice(order.total, order.currency.toUpperCase())} + + + + + )) + )} + +
+
+ + {/* Pagination footer — list mode only (not shown during client-side search) */} + {!isSearching && ( +
+ + {status === "Exhausted" + ? `${results.length} order${results.length !== 1 ? "s" : ""} total` + : `${results.length} loaded`} + + {status === "CanLoadMore" && ( + + )} +
+ )} +
+ ) } diff --git a/apps/admin/src/components/orders/actions/AcceptReturnButton.tsx b/apps/admin/src/components/orders/actions/AcceptReturnButton.tsx new file mode 100644 index 0000000..b18b8cb --- /dev/null +++ b/apps/admin/src/components/orders/actions/AcceptReturnButton.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useState } from "react" +import { useAction } from "convex/react" +import { toast } from "sonner" +import { api } from "../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../convex/_generated/dataModel" +import { Button } from "@/components/ui/button" + +interface Props { + orderId: Id<"orders"> +} + +export function AcceptReturnButton({ orderId }: Props) { + const [isLoading, setIsLoading] = useState(false) + const acceptReturn = useAction(api.returnActions.acceptReturn) + + async function handleClick() { + setIsLoading(true) + try { + const result = await acceptReturn({ orderId }) + if (result.success) { + toast.success(`Return accepted. Tracking: ${result.returnTrackingNumber}`) + } else { + toast.error((result as { success: false; code: string; message: string }).message) + } + } catch (e: any) { + toast.error(e?.message ?? "Failed to accept return.") + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} diff --git a/apps/admin/src/components/orders/actions/CreateLabelButton.tsx b/apps/admin/src/components/orders/actions/CreateLabelButton.tsx new file mode 100644 index 0000000..a79957d --- /dev/null +++ b/apps/admin/src/components/orders/actions/CreateLabelButton.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useState } from "react" +import { useAction } from "convex/react" +import { toast } from "sonner" +import { api } from "../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../convex/_generated/dataModel" +import { Button } from "@/components/ui/button" + +interface Props { + orderId: Id<"orders"> +} + +export function CreateLabelButton({ orderId }: Props) { + const [isLoading, setIsLoading] = useState(false) + const createLabel = useAction(api.fulfillmentActions.createShippingLabel) + + async function handleClick() { + setIsLoading(true) + try { + const result = await createLabel({ orderId }) + if (result.success) { + toast.success(`Label created. Tracking: ${result.trackingNumber}`) + } else { + toast.error((result as { success: false; code: string; message: string }).message) + } + } catch (e: any) { + toast.error(e?.message ?? "Failed to create shipping label.") + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} diff --git a/apps/admin/src/components/orders/actions/IssueRefundButton.tsx b/apps/admin/src/components/orders/actions/IssueRefundButton.tsx new file mode 100644 index 0000000..11a7fbe --- /dev/null +++ b/apps/admin/src/components/orders/actions/IssueRefundButton.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useState } from "react" +import { useAction } from "convex/react" +import { toast } from "sonner" +import { formatPrice } from "@repo/utils" +import { api } from "../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../convex/_generated/dataModel" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface Props { + orderId: Id<"orders"> + total: number + currency: string +} + +export function IssueRefundButton({ orderId, total, currency }: Props) { + const [open, setOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const issueRefund = useAction(api.returnActions.issueRefund) + + const formattedTotal = formatPrice(total, currency.toUpperCase()) + + async function handleConfirm() { + setIsLoading(true) + try { + await issueRefund({ orderId }) + toast.success("Refund issued.") + setOpen(false) + } catch (e: any) { + toast.error(e?.message ?? "Failed to issue refund.") + } finally { + setIsLoading(false) + } + } + + return ( + <> + + + + + + Issue full refund? + + A full refund of {formattedTotal} will be sent to the customer via + Stripe. This cannot be undone. + + + + Cancel + + {isLoading && ( + + )} + {isLoading ? "Refunding…" : "Issue Refund"} + + + + + + ) +} diff --git a/apps/admin/src/components/orders/actions/MarkReturnReceivedButton.tsx b/apps/admin/src/components/orders/actions/MarkReturnReceivedButton.tsx new file mode 100644 index 0000000..3247319 --- /dev/null +++ b/apps/admin/src/components/orders/actions/MarkReturnReceivedButton.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useState } from "react" +import { useMutation } from "convex/react" +import { toast } from "sonner" +import { api } from "../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../convex/_generated/dataModel" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface Props { + orderId: Id<"orders"> +} + +export function MarkReturnReceivedButton({ orderId }: Props) { + const [open, setOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const markReceived = useMutation(api.orders.markReturnReceived) + + async function handleConfirm() { + setIsLoading(true) + try { + await markReceived({ id: orderId }) + toast.success("Return marked as received.") + setOpen(false) + } catch (e: any) { + toast.error(e?.message ?? "Failed to mark return as received.") + } finally { + setIsLoading(false) + } + } + + return ( + <> + + + + + + Mark return as received? + + Confirm that the returned items have arrived. You can then issue a + refund. + + + + Cancel + + {isLoading && ( + + )} + {isLoading ? "Saving…" : "Confirm"} + + + + + + ) +} diff --git a/apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx b/apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx new file mode 100644 index 0000000..ddad29c --- /dev/null +++ b/apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useState } from "react" +import { useMutation } from "convex/react" +import { toast } from "sonner" +import { api } from "../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../convex/_generated/dataModel" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ORDER_STATUS_CONFIG, + type OrderStatus, +} from "../shared/statusConfig" + +interface Props { + orderId: Id<"orders"> + currentStatus: string +} + +const ALL_STATUSES = Object.keys(ORDER_STATUS_CONFIG) as OrderStatus[] + +export function UpdateStatusDialog({ orderId, currentStatus }: Props) { + const [open, setOpen] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const availableStatuses = ALL_STATUSES.filter((s) => s !== currentStatus) + const [selectedStatus, setSelectedStatus] = useState( + availableStatuses[0] ?? "", + ) + + const updateStatus = useMutation(api.orders.updateStatus) + + // Reset selection to first available when dialog opens + function handleOpenChange(next: boolean) { + if (next) { + setSelectedStatus(availableStatuses[0] ?? "") + } + setOpen(next) + } + + async function handleSubmit() { + if (!selectedStatus) return + setIsSubmitting(true) + try { + await updateStatus({ + id: orderId, + status: selectedStatus as OrderStatus, + }) + const label = ORDER_STATUS_CONFIG[selectedStatus as OrderStatus]?.label + toast.success(`Status updated to ${label ?? selectedStatus}`) + setOpen(false) + } catch (e: any) { + toast.error(e?.message ?? "Failed to update status.") + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + + + + + + Update order status + + + + + + + + + + + + ) +} diff --git a/apps/admin/src/components/orders/detail/CustomerCard.tsx b/apps/admin/src/components/orders/detail/CustomerCard.tsx new file mode 100644 index 0000000..aa9b1c6 --- /dev/null +++ b/apps/admin/src/components/orders/detail/CustomerCard.tsx @@ -0,0 +1,25 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface Props { + name: string + email: string +} + +export function CustomerCard({ name, email }: Props) { + return ( + + + Customer + + +

{name}

+ + {email} + +
+
+ ) +} diff --git a/apps/admin/src/components/orders/detail/FulfilmentCard.tsx b/apps/admin/src/components/orders/detail/FulfilmentCard.tsx new file mode 100644 index 0000000..076da69 --- /dev/null +++ b/apps/admin/src/components/orders/detail/FulfilmentCard.tsx @@ -0,0 +1,157 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { HugeiconsIcon } from "@hugeicons/react" +import { ExternalLink } from "@hugeicons/core-free-icons" + +interface Props { + carrier: string + shippingMethod: string + shippingServiceCode: string + trackingNumber?: string + trackingUrl?: string + labelUrl?: string + returnLabelUrl?: string + trackingStatus?: string + estimatedDelivery?: number + actualDelivery?: number + status: string +} + +function InfoRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function ExternalLinkIcon() { + return ( +