chore: add client/src/pages/admin/orders.tsx

This commit is contained in:
notshop 2026-04-26 16:36:07 +00:00
parent e122466c8d
commit dfba4f08fb

View file

@ -0,0 +1,183 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "../../lib/queryClient";
import { AdminNav } from "./dashboard";
import { toast } from "../../hooks/use-toast";
import { Search, ChevronDown, ChevronUp } from "lucide-react";
interface Order {
id: number;
orderNumber: string;
email: string;
status: string;
paymentStatus: string;
fulfillmentStatus: string;
total: string;
subtotal: string;
discountAmount?: string;
discountCode?: string;
shipping: string;
tax: string;
paymentProcessor: string;
trackingNumber?: string;
trackingCarrier?: string;
shippingAddress?: any;
notes?: string;
createdAt: string;
}
const STATUS_OPTIONS = ["", "pending", "confirmed", "fulfilled", "cancelled", "refunded"];
const FULFILLMENT_OPTIONS = ["unfulfilled", "partial", "fulfilled", "returned"];
function OrderRow({ order }: { order: Order }) {
const [expanded, setExpanded] = useState(false);
const [tracking, setTracking] = useState(order.trackingNumber || "");
const [carrier, setCarrier] = useState(order.trackingCarrier || "");
const [status, setStatus] = useState(order.status);
const [saving, setSaving] = useState(false);
const updateMutation = useMutation({
mutationFn: (body: object) => apiRequest("PATCH", `/api/admin/orders/${order.id}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/orders"] });
toast({ title: "Order updated" });
setSaving(false);
},
onError: (err: any) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
setSaving(false);
},
});
const addr = order.shippingAddress;
return (
<div className="border border-border rounded-xl overflow-hidden" data-testid={`order-row-${order.id}`}>
<div className="flex items-center gap-4 p-4 cursor-pointer hover:bg-muted/30 transition-colors" onClick={() => setExpanded((v) => !v)}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono font-medium text-sm">#{order.orderNumber}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${order.status === "confirmed" ? "bg-blue-100 text-blue-700" : order.status === "fulfilled" ? "bg-green-100 text-green-700" : order.status === "cancelled" ? "bg-red-100 text-red-700" : "bg-muted text-muted-foreground"}`}>{order.status}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${order.fulfillmentStatus === "fulfilled" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"}`}>{order.fulfillmentStatus}</span>
</div>
<p className="text-sm text-muted-foreground truncate">{order.email}</p>
</div>
<div className="text-right">
<p className="font-semibold">${parseFloat(order.total).toFixed(2)}</p>
<p className="text-xs text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</p>
</div>
{expanded ? <ChevronUp size={14} className="flex-shrink-0 text-muted-foreground" /> : <ChevronDown size={14} className="flex-shrink-0 text-muted-foreground" />}
</div>
{expanded && (
<div className="border-t border-border p-4 bg-muted/10 grid md:grid-cols-2 gap-6">
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Ship to</p>
{addr ? (
<p className="text-sm">{addr.firstName} {addr.lastName}<br />{addr.address1}{addr.address2 ? `, ${addr.address2}` : ""}<br />{addr.city}, {addr.state} {addr.zip}</p>
) : <p className="text-sm text-muted-foreground">No address</p>}
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Payment</p>
<p className="text-sm capitalize">{order.paymentProcessor} {order.paymentStatus}</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Order status</label>
<select value={status} onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-1.5 border border-border rounded-md text-sm bg-background focus:outline-none">
{STATUS_OPTIONS.filter(Boolean).map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Tracking #</label>
<input value={tracking} onChange={(e) => setTracking(e.target.value)} placeholder="e.g. 1Z..."
className="w-full px-3 py-1.5 border border-border rounded-md text-sm bg-background focus:outline-none"
data-testid={`input-tracking-${order.id}`} />
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Carrier</label>
<select value={carrier} onChange={(e) => setCarrier(e.target.value)}
className="w-full px-3 py-1.5 border border-border rounded-md text-sm bg-background focus:outline-none">
<option value="">Select</option>
{["UPS","USPS","FedEx","DHL","Other"].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<button
onClick={() => { setSaving(true); updateMutation.mutate({ status, trackingNumber: tracking || undefined, trackingCarrier: carrier || undefined }); }}
disabled={saving}
className="w-full bg-primary text-primary-foreground py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-60"
data-testid={`button-save-order-${order.id}`}>
{saving ? "Saving..." : "Save changes"}
</button>
</div>
</div>
)}
</div>
);
}
export default function AdminOrders() {
const [q, setQ] = useState("");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery<{ orders: Order[]; total: number }>({
queryKey: ["/api/admin/orders", { q: search, status: statusFilter, page }],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set("q", search);
if (statusFilter) params.set("status", statusFilter);
const r = await fetch(`/api/admin/orders?${params}`, { credentials: "include" });
if (r.status === 401) { window.location.href = "/admin/login"; return { orders: [], total: 0 }; }
return r.json();
},
});
return (
<div className="flex h-screen overflow-hidden" data-testid="admin-orders">
<AdminNav active="/admin/orders" />
<main className="flex-1 overflow-y-auto p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Orders {data?.total !== undefined && <span className="text-muted-foreground text-lg">({data.total})</span>}</h1>
<div className="flex gap-2">
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none">
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s || "All statuses"}</option>)}
</select>
<form onSubmit={(e) => { e.preventDefault(); setSearch(q); setPage(1); }} className="flex gap-2">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search orders..."
className="pl-8 pr-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none w-52" />
</div>
<button type="submit" className="px-3 py-2 bg-primary text-primary-foreground rounded-md text-sm">Search</button>
</form>
</div>
</div>
{isLoading ? (
<div className="space-y-3">{[1,2,3,4,5].map((i) => <div key={i} className="h-16 bg-muted rounded-xl animate-pulse" />)}</div>
) : (
<div className="space-y-3">
{data?.orders.map((o) => <OrderRow key={o.id} order={o} />)}
{!data?.orders.length && <p className="text-center py-12 text-muted-foreground">No orders found</p>}
</div>
)}
{data && data.total > 20 && (
<div className="flex justify-center gap-3 mt-6">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1} className="px-4 py-2 border border-border rounded-md text-sm disabled:opacity-40">Previous</button>
<span className="px-4 py-2 text-sm text-muted-foreground">Page {page}</span>
<button onClick={() => setPage((p) => p + 1)} disabled={page * 20 >= data.total} className="px-4 py-2 border border-border rounded-md text-sm disabled:opacity-40">Next</button>
</div>
)}
</main>
</div>
);
}