feat: Implement Order Detail View
This commit is contained in:
@@ -1,9 +1,451 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock } from "lucide-react";
|
||||||
|
|
||||||
const OrderDetail = () => {
|
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
|
||||||
|
import { Button } from "@/common/components/ui/button";
|
||||||
|
import { Badge } from "@/common/components/ui/badge";
|
||||||
|
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||||
|
import { useGetOrderById, useUpdateOrder } from "@/dataconnect-generated/react";
|
||||||
|
import { OrderStatus } from "@/dataconnect-generated";
|
||||||
|
import { dataConnect } from "@/features/auth/firebase";
|
||||||
|
import { useToast } from "@/common/components/ui/use-toast";
|
||||||
|
import type { RootState } from "@/store/store";
|
||||||
|
|
||||||
|
const safeFormatDate = (value?: string | null): string => {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return format(d, "MMM d, yyyy");
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeFormatDateTime = (value?: string | null): string => {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return format(d, "MMM d, yyyy • h:mm a");
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: OrderStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case OrderStatus.FULLY_STAFFED:
|
||||||
|
case OrderStatus.FILLED:
|
||||||
return (
|
return (
|
||||||
<div>OrderDetail</div>
|
<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-slate-700 hover:bg-slate-800 text-white border-none font-bold uppercase text-[10px]">
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case OrderStatus.DRAFT:
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="font-bold uppercase text-[10px]">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderDetail() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
} = useGetOrderById(
|
||||||
|
dataConnect,
|
||||||
|
{ id: id || "" },
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = data?.order;
|
||||||
|
|
||||||
|
const cancelMutation = useUpdateOrder(dataConnect, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Order cancelled",
|
||||||
|
description: "The order status has been updated to Cancelled.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to cancel order",
|
||||||
|
description: "Please try again or contact support.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canModify = useMemo(() => {
|
||||||
|
if (!order) return false;
|
||||||
|
const status = order.status as OrderStatus;
|
||||||
|
return status !== OrderStatus.CANCELLED && status !== OrderStatus.COMPLETED;
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (!order || !id || !canModify) return;
|
||||||
|
cancelMutation.mutate({
|
||||||
|
id,
|
||||||
|
status: OrderStatus.CANCELLED,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!order || !id) return;
|
||||||
|
// Placeholder: route can later be wired to an edit form
|
||||||
|
navigate(`/orders/create?edit=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
if (!order || !id) return;
|
||||||
|
// Placeholder: route can later pre-fill a new order from this one
|
||||||
|
navigate(`/orders/create?duplicate=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shifts: any[] = Array.isArray(order?.shifts) ? (order!.shifts as any[]) : [];
|
||||||
|
|
||||||
|
const totalRequested = order?.requested ?? 0;
|
||||||
|
const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 0;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Order Detail" subtitle="Loading order details">
|
||||||
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrderDetail
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Order Not Found" subtitle="The requested order could not be located">
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[40vh] space-y-4">
|
||||||
|
<p className="text-muted-foreground">This order may have been deleted or the link is invalid.</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate("/orders")}>
|
||||||
|
Back to Orders
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClient = user?.userRole === "client";
|
||||||
|
|
||||||
|
const clientName = order.business?.businessName || "Unknown client";
|
||||||
|
const eventDateLabel = safeFormatDate(order.date as string | null);
|
||||||
|
const locationLabel = order.business?.businessName || "—";
|
||||||
|
|
||||||
|
const timelineItems = [
|
||||||
|
{
|
||||||
|
label: "Order created",
|
||||||
|
value: safeFormatDateTime(order.createdAt as string | null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Event date",
|
||||||
|
value: eventDateLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Current status",
|
||||||
|
value: (order.status as string) || "—",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout
|
||||||
|
title={order.eventName || "Order Detail"}
|
||||||
|
subtitle="Detailed view of this order and its shifts"
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusBadge(order.status as OrderStatus)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={!canModify || !isClient}
|
||||||
|
className="rounded-xl"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
className="rounded-xl"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={!canModify || cancelMutation.isPending}
|
||||||
|
className="rounded-xl"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
{cancelMutation.isPending ? "Cancelling..." : "Cancel Order"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header / Key Info */}
|
||||||
|
<Card className="bg-card border-border/50 shadow-sm">
|
||||||
|
<CardHeader className="border-b border-border/40">
|
||||||
|
<CardTitle className="text-lg font-bold text-primary-text">Order Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
|
||||||
|
<FileTextIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Client Name
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">{clientName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center border border-blue-500/20">
|
||||||
|
<Calendar className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Event Date
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">{eventDateLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-purple-500/10 rounded-xl flex items-center justify-center border border-purple-500/20">
|
||||||
|
<MapPin className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Location
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">{locationLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-emerald-500/10 rounded-xl flex items-center justify-center border border-emerald-500/20">
|
||||||
|
<Users className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Staffed / Requested
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">
|
||||||
|
{totalAssigned} / {totalRequested}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Shifts Section */}
|
||||||
|
<Card className="bg-card border-border/50 shadow-sm">
|
||||||
|
<CardHeader className="border-b border-border/40">
|
||||||
|
<CardTitle className="text-lg font-bold text-primary-text">Shifts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
{shifts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
|
||||||
|
<Users className="w-10 h-10 mb-3 text-muted-foreground/40" />
|
||||||
|
<p className="font-medium">No shifts defined for this order.</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Add shifts when creating or editing the order to see them here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shifts.map((shift: any, index: number) => {
|
||||||
|
const start = safeFormatDateTime(shift.startTime || shift.start || shift.date);
|
||||||
|
const end = safeFormatDateTime(shift.endTime || shift.end);
|
||||||
|
const title = shift.title || shift.positionName || shift.roleName || `Shift #${index + 1}`;
|
||||||
|
const workersNeeded = shift.workersNeeded ?? shift.requested ?? 0;
|
||||||
|
const filled =
|
||||||
|
typeof shift.filled === "number"
|
||||||
|
? shift.filled
|
||||||
|
: Array.isArray(shift.assignedStaff)
|
||||||
|
? shift.assignedStaff.length
|
||||||
|
: 0;
|
||||||
|
const vacancies = Math.max(workersNeeded - filled, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col md:flex-row md:items-center justify-between gap-3 border border-border/60 rounded-xl px-4 py-3 bg-background/40"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-primary-text">{title}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
{start} {end !== "—" && `→ ${end}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
Required
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{workersNeeded || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
Assigned
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{filled}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
Vacancies
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{vacancies}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Timeline Section */}
|
||||||
|
<Card className="bg-card border-border/50 shadow-sm">
|
||||||
|
<CardHeader className="border-b border-border/40">
|
||||||
|
<CardTitle className="text-lg font-bold text-primary-text">
|
||||||
|
Order Status Timeline
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="relative pl-4 border-l border-border/70 space-y-5">
|
||||||
|
{timelineItems.map((item, idx) => (
|
||||||
|
<div key={idx} className="relative flex flex-col gap-1">
|
||||||
|
<div className="absolute -left-[9px] top-1 w-4 h-4 rounded-full border-2 border-primary bg-background" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-primary-text">{item.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Financial Summary (optional helper, derived from existing fields) */}
|
||||||
|
<Card className="bg-card border-border/50 shadow-sm">
|
||||||
|
<CardHeader className="border-b border-border/40">
|
||||||
|
<CardTitle className="text-lg font-bold text-primary-text">Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center border border-amber-500/20">
|
||||||
|
<DollarSign className="w-6 h-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Estimated Total
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">
|
||||||
|
{typeof order.total === "number" ? `$${order.total.toLocaleString()}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-slate-500/10 rounded-xl flex items-center justify-center border border-slate-500/20">
|
||||||
|
<Users className="w-6 h-6 text-slate-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Total Positions
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">{totalRequested}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
|
||||||
|
<Clock className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
|
Order Type
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-primary-text">
|
||||||
|
{(order.orderType as string)?.replace("_", " ") || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTextIcon: React.FC = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="w-6 h-6 text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.414a2 2 0 0 0-.586-1.414l-4.414-4.414A2 2 0 0 0 13.586 2H7zm6 2.414L17.586 9H15a2 2 0 0 1-2-2V4.414zM9 11h6v2H9v-2zm0 4h4v2H9v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user