feat: Implement Order List for Admins
This commit is contained in:
9
apps/web/src/features/operations/orders/OrderDetail.tsx
Normal file
9
apps/web/src/features/operations/orders/OrderDetail.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
const OrderDetail = () => {
|
||||
return (
|
||||
<div>OrderDetail</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderDetail
|
||||
343
apps/web/src/features/operations/orders/OrderList.tsx
Normal file
343
apps/web/src/features/operations/orders/OrderList.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { Badge } from "@/common/components/ui/badge";
|
||||
import { Button } from "@/common/components/ui/button";
|
||||
import { Card, CardContent } from "@/common/components/ui/card";
|
||||
import { Input } from "@/common/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/common/components/ui/select";
|
||||
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||
import {
|
||||
Search,
|
||||
Calendar,
|
||||
Filter,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/store/store";
|
||||
import { useListOrders, useListBusinesses } from "@/dataconnect-generated/react";
|
||||
import { dataConnect } from "@/features/auth/firebase";
|
||||
import { format, isWithinInterval, parseISO, startOfDay, endOfDay } from "date-fns";
|
||||
import { OrderStatus } from "@/dataconnect-generated";
|
||||
|
||||
export default function OrderList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [clientFilter, setClientFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: "", end: "" });
|
||||
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const isAdmin = user?.userRole === 'admin' || user?.userRole === 'ADMIN';
|
||||
|
||||
const { data: orderData, isLoading: loadingOrders } = useListOrders(dataConnect);
|
||||
const { data: businessData, isLoading: loadingBusinesses } = useListBusinesses(dataConnect);
|
||||
|
||||
const isLoading = loadingOrders || loadingBusinesses;
|
||||
const orders = orderData?.orders || [];
|
||||
const businesses = businessData?.businesses || [];
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
return orders.filter(order => {
|
||||
// Search by Order # (ID) or Event Name
|
||||
const matchesSearch =
|
||||
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(order.eventName?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||
|
||||
// Filter by Status
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
// Filter by Client
|
||||
const matchesClient = clientFilter === "all" || order.businessId === clientFilter;
|
||||
|
||||
// Filter by Date Range
|
||||
let matchesDate = true;
|
||||
if (order.date) {
|
||||
const orderDate = new Date(order.date);
|
||||
if (dateRange.start && dateRange.end) {
|
||||
matchesDate = isWithinInterval(orderDate, {
|
||||
start: startOfDay(new Date(dateRange.start)),
|
||||
end: endOfDay(new Date(dateRange.end))
|
||||
});
|
||||
} else if (dateRange.start) {
|
||||
matchesDate = orderDate >= startOfDay(new Date(dateRange.start));
|
||||
} else if (dateRange.end) {
|
||||
matchesDate = orderDate <= endOfDay(new Date(dateRange.end));
|
||||
}
|
||||
}
|
||||
|
||||
return matchesSearch && matchesStatus && matchesClient && matchesDate;
|
||||
});
|
||||
}, [orders, searchTerm, statusFilter, clientFilter, dateRange]);
|
||||
|
||||
const getStatusBadge = (status: OrderStatus) => {
|
||||
switch (status) {
|
||||
case OrderStatus.FULLY_STAFFED:
|
||||
case OrderStatus.FILLED:
|
||||
return <Badge className="bg-emerald-500 hover:bg-emerald-600 text-white border-none font-bold uppercase text-[10px]">Fully Staffed</Badge>;
|
||||
case OrderStatus.PARTIAL_STAFFED:
|
||||
return <Badge className="bg-orange-500 hover:bg-orange-600 text-white border-none font-bold uppercase text-[10px]">Partial Staffed</Badge>;
|
||||
case OrderStatus.PENDING:
|
||||
case OrderStatus.POSTED:
|
||||
return <Badge className="bg-blue-500 hover:bg-blue-600 text-white border-none font-bold uppercase text-[10px]">{status}</Badge>;
|
||||
case OrderStatus.CANCELLED:
|
||||
return <Badge className="bg-red-500 hover:bg-red-600 text-white border-none font-bold uppercase text-[10px]">Cancelled</Badge>;
|
||||
case OrderStatus.COMPLETED:
|
||||
return <Badge className="bg-green-500 hover:bg-slate-600 text-white border-none font-bold uppercase text-[10px]">Completed</Badge>;
|
||||
case OrderStatus.DRAFT:
|
||||
return <Badge variant="outline" className="text-muted-foreground font-bold uppercase text-[10px]">Draft</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary" className="font-bold uppercase text-[10px]">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateFillRate = (order: any) => {
|
||||
const requested = order.requested || 0;
|
||||
const assigned = Array.isArray(order.assignedStaff) ? order.assignedStaff.length : 0;
|
||||
|
||||
if (requested === 0) return 0;
|
||||
return Math.round((assigned / requested) * 100);
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<DashboardLayout title="Access Denied" subtitle="Unauthorized Access">
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
|
||||
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center text-destructive mb-4">
|
||||
<AlertTriangle className="w-8 h-8" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Restricted Access</h2>
|
||||
<p className="text-muted-foreground mt-2 max-w-sm">Only administrators are authorized to view the master order list.</p>
|
||||
<Button onClick={() => navigate("/")} variant="outline" className="mt-6 rounded-xl font-bold">
|
||||
Return to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Master Order List"
|
||||
subtitle="Monitor and manage all client orders across the platform"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* KPI Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-card/50 border-border/50 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Total Orders</p>
|
||||
<p className="text-2xl font-bold">{orders.length}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border/50 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Pending/Posted</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{orders.filter(o => o.status === OrderStatus.PENDING || o.status === OrderStatus.POSTED).length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-600">
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border/50 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Partial</p>
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{orders.filter(o => o.status === OrderStatus.PARTIAL_STAFFED).length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-orange-500/10 rounded-lg text-orange-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border/50 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Filled</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
{orders.filter(o => o.status === OrderStatus.FULLY_STAFFED || o.status === OrderStatus.FILLED).length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-emerald-500/10 rounded-lg text-emerald-600">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters Section */}
|
||||
<div className="bg-card/30 p-4 rounded-2xl border border-border/50 flex flex-col lg:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by Order # or Event Name..."
|
||||
className="pl-10 h-11"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-11 min-w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{Object.values(OrderStatus).map(status => (
|
||||
<SelectItem key={status} value={status}>{status.replace('_', ' ')}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={clientFilter} onValueChange={setClientFilter}>
|
||||
<SelectTrigger className="h-11 min-w-[160px]">
|
||||
<SelectValue placeholder="Client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Clients</SelectItem>
|
||||
{businesses.map(business => (
|
||||
<SelectItem key={business.id} value={business.id}>{business.businessName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2 col-span-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
|
||||
/>
|
||||
<div className="flex items-center text-muted-foreground">to</div>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-11"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master Table */}
|
||||
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Order #</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Client Name</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Event Date</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Status</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-center">Positions</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Fill Rate</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground font-medium">Fetching orders...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredOrders.length > 0 ? (
|
||||
filteredOrders.map((order) => {
|
||||
const fillRate = calculateFillRate(order);
|
||||
return (
|
||||
<tr
|
||||
key={order.id}
|
||||
className="hover:bg-muted/40 cursor-pointer transition-colors group"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-foreground group-hover:text-primary transition-colors truncate max-w-[120px]">
|
||||
#{order.id.split('-')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase">{order.eventName || 'Unnamed Event'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium">{order.business.businessName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{order.date ? format(new Date(order.date), 'MMM d, yyyy') : 'TBD'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="text-sm font-bold bg-muted/50 px-2 py-1 rounded border border-border inline-block min-w-[30px]">
|
||||
{order.requested || 0}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1.5 w-full max-w-[100px]">
|
||||
<div className="flex justify-between text-[10px] font-bold">
|
||||
<span>{fillRate}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
fillRate === 100 ? 'bg-emerald-500' : fillRate > 0 ? 'bg-orange-500' : 'bg-slate-300'
|
||||
}`}
|
||||
style={{ width: `${fillRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-muted-foreground">
|
||||
No orders found matching your criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user