chore: add client/src/pages/admin/orders.tsx
This commit is contained in:
parent
e122466c8d
commit
dfba4f08fb
1 changed files with 183 additions and 0 deletions
183
client/src/pages/admin/orders.tsx
Normal file
183
client/src/pages/admin/orders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue