diff --git a/apps/web/package.json b/apps/web/package.json index adc206a8..2a5f31fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@firebase/analytics": "^0.10.19", "@firebase/data-connect": "^0.3.12", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 1f511454..5de2cfe2 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@firebase/data-connect': specifier: ^0.3.12 version: 0.3.12(@firebase/app@0.14.7) + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -828,6 +831,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -898,6 +914,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -3671,6 +3696,19 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3741,6 +3779,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/apps/web/src/common/components/ui/avatar.tsx b/apps/web/src/common/components/ui/avatar.tsx new file mode 100644 index 00000000..7c0eb5ff --- /dev/null +++ b/apps/web/src/common/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/src/features/operations/orders/EditOrder.tsx b/apps/web/src/features/operations/orders/EditOrder.tsx new file mode 100644 index 00000000..83d62fb2 --- /dev/null +++ b/apps/web/src/features/operations/orders/EditOrder.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { createPageUrl } from "@/lib/index"; +import { Button } from "@/common/components/ui/button"; +import { Loader2, AlertTriangle } from "lucide-react"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import OrderReductionAlert from "./components/OrderReductionAlert"; +import EventFormWizard from "./components/EventFormWizard"; +import { useToast } from "@/common/components/ui/use-toast"; +import { useGetOrderById, useUpdateOrder, useListStaff } from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import type { RootState } from "@/store/store"; + + +export default function EditOrder() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const { id: eventId } = useParams<{ id: string }>(); + const { user } = useSelector((state: RootState) => state.auth); + + const [showReductionAlert, setShowReductionAlert] = useState(false); + const [pendingUpdate, setPendingUpdate] = useState(null); + const [originalRequested, setOriginalRequested] = useState(0); + + const { data: orderData, isLoading: isOrderLoading } = useGetOrderById( + dataConnect, + { id: eventId || "" }, + { enabled: !!eventId } + ); + + const event = orderData?.order; + + const { data: staffData } = useListStaff(dataConnect); + const allStaff = staffData?.staffs || []; + + useEffect(() => { + if (event) { + setOriginalRequested(event.requested || 0); + } + }, [event]); + + const updateOrderMutation = useUpdateOrder(dataConnect, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listOrders"] }); + queryClient.invalidateQueries({ queryKey: ["getOrderById", { id: eventId }] }); + toast({ + title: "✅ Order Updated", + description: "Your changes have been saved successfully.", + }); + navigate(createPageUrl("Events")); + }, + onError: (error) => { + toast({ + title: "❌ Update Failed", + description: error.message || "Could not update the order. Please try again.", + variant: "destructive", + }); + }, + }); + + const canModify = useMemo(() => { + if (!event) return false; + if (!event.startDate) return true; + const startTime = new Date(event.startDate); + return startTime > new Date(); + }, [event]); + + const handleSubmit = (eventData: any) => { + if (!canModify) { + toast({ + title: "Cannot Edit Order", + description: "This order has already started and cannot be modified.", + variant: "destructive", + }); + return; + } + + // CRITICAL: Recalculate requested count from current roles + const totalRequested = eventData.shifts.reduce((sum: number, shift: any) => { + const roles = Array.isArray(shift.roles) ? shift.roles : []; + return sum + roles.reduce((roleSum: number, role: any) => roleSum + (parseInt(role.count) || 0), 0); + }, 0); + + const assignedStaff = Array.isArray(event?.assignedStaff) ? event!.assignedStaff : []; + const assignedCount = assignedStaff.length; + const isVendor = user?.userRole === 'vendor' || (user as any)?.role === 'vendor'; + + // If client is reducing headcount and vendor has already assigned staff + if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) { + setPendingUpdate({ ...eventData, requested: totalRequested }); + setShowReductionAlert(true); + + toast({ + title: "⚠️ Headcount Reduced", + description: "Assigned staff exceeds new headcount. Please resolve assignments.", + }); + return; + } + + if (eventId) { + // Normal update + updateOrderMutation.mutate({ + id: eventId, + eventName: eventData.event_name, + date: eventData.date, + startDate: eventData.startDate || eventData.date, + endDate: eventData.endDate, + notes: eventData.notes, + shifts: eventData.shifts, + requested: totalRequested, + total: eventData.total, + poReference: eventData.po_reference, + }); + } + }; + + const handleAutoUnassign = async () => { + if (!pendingUpdate || !event || !eventId) return; + + const assignedStaff = Array.isArray(event.assignedStaff) ? (event.assignedStaff as any[]) : []; + const excessCount = assignedStaff.length - pendingUpdate.requested; + + // Calculate reliability scores for assigned staff + const staffWithScores = assignedStaff.map(assigned => { + const staffInfo = allStaff.find(s => s.id === assigned.staff_id || s.id === assigned.staffId); + return { + ...assigned, + reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50, // Convert 0-5 to 0-100 + }; + }); + + // Sort by reliability (lowest first) + staffWithScores.sort((a: any, b: any) => a.reliability - b.reliability); + + // Remove lowest reliability staff + const staffToKeep = staffWithScores.slice(excessCount); + + await updateOrderMutation.mutateAsync({ + id: eventId, + ...pendingUpdate, + assignedStaff: staffToKeep.map((s: any) => ({ + staffId: s.staffId || s.staff_id, + staffName: s.staffName || s.staff_name, + role: s.role + })) + }); + + setShowReductionAlert(false); + setPendingUpdate(null); + + toast({ + title: "✅ Staff Auto-Unassigned", + description: `Removed ${excessCount} lowest reliability staff members`, + }); + }; + + const handleManualUnassign = () => { + setShowReductionAlert(false); + toast({ + title: "Manual Adjustment Required", + description: "Please manually remove excess staff from the order", + }); + }; + + if (isOrderLoading) { + return ( +
+ +
+ ); + } + + if (!event) { + return ( +
+

Event Not Found

+ +
+ ); + } + + return ( + +
+ {!canModify && ( +
+ +

+ This order has already started. Some details may no longer be editable for security and tracking purposes. +

+
+ )} + + {showReductionAlert && pendingUpdate && ( +
+ { + const staffInfo = allStaff.find(s => s.id === assigned.staffId || s.id === assigned.staff_id); + return { + name: assigned.staffName || assigned.staff_name, + reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50 + }; + }).sort((a, b) => a.reliability - b.reliability)} + /> +
+ )} + + navigate(createPageUrl("Events"))} + /> +
+
+ ); +} diff --git a/apps/web/src/features/operations/orders/OrderDetail.tsx b/apps/web/src/features/operations/orders/OrderDetail.tsx index 8c3c0388..92d416bc 100644 --- a/apps/web/src/features/operations/orders/OrderDetail.tsx +++ b/apps/web/src/features/operations/orders/OrderDetail.tsx @@ -131,8 +131,7 @@ export default function OrderDetail() { const handleEdit = () => { if (!order || !id) return; - // Placeholder: route can later be wired to an edit form - navigate(`/orders/create?edit=${id}`); + navigate(`/orders/${id}/edit`); }; const handleDuplicate = () => { diff --git a/apps/web/src/features/operations/orders/components/EventFormWizard.tsx b/apps/web/src/features/operations/orders/components/EventFormWizard.tsx new file mode 100644 index 00000000..0667dc88 --- /dev/null +++ b/apps/web/src/features/operations/orders/components/EventFormWizard.tsx @@ -0,0 +1,339 @@ +import React, { useState } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { Button } from "@/common/components/ui/button"; +import { Input } from "@/common/components/ui/input"; +import { Label } from "@/common/components/ui/label"; +import { Textarea } from "@/common/components/ui/textarea"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { + Plus, + Trash2, + Info, + ChevronRight, + ChevronLeft, + Save, + X, + PlusCircle +} from "lucide-react"; +import { format } from "date-fns"; + +interface Role { + name: string; + count: number; + rate?: number; +} + +interface Shift { + id?: string; + title: string; + startTime: string; + endTime: string; + roles: Role[]; +} + +interface EventFormData { + event_name: string; + date: string; + startDate: string; + endDate: string; + notes: string; + po_reference: string; + total: number; + shifts: Shift[]; +} + +interface EventFormWizardProps { + event: any; + onSubmit: (data: any) => void; + isSubmitting: boolean; + currentUser: any; + onCancel: () => void; +} + +export default function EventFormWizard({ + event, + onSubmit, + isSubmitting, + onCancel +}: EventFormWizardProps) { + const [step, setStep] = useState(1); + + const { register, control, handleSubmit, formState: { errors } } = useForm({ + defaultValues: { + event_name: event?.eventName || "", + date: event?.date ? format(new Date(event.date), "yyyy-MM-dd") : "", + startDate: event?.startDate ? format(new Date(event.startDate), "yyyy-MM-dd'T'HH:mm") : "", + endDate: event?.endDate ? format(new Date(event.endDate), "yyyy-MM-dd'T'HH:mm") : "", + notes: event?.notes || "", + po_reference: event?.poReference || "", + total: event?.total || 0, + shifts: event?.shifts?.map((s: any) => ({ + ...s, + roles: Array.isArray(s.roles) ? s.roles : [] + })) || [] + } + }); + + const { fields: shiftFields, append: appendShift, remove: removeShift } = useFieldArray({ + control, + name: "shifts" + }); + + const nextStep = () => setStep(prev => prev + 1); + const prevStep = () => setStep(prev => prev - 1); + + return ( +
+
+
+ {[1, 2].map((i) => ( + +
= i ? "bg-primary text-white" : "bg-muted text-muted-foreground" + }`} + > + {i} +
+ {i < 2 &&
i ? "bg-primary" : "bg-muted"}`} />} + + ))} +
+
+

+ Step {step} of 2 +

+

+ {step === 1 ? "Order Details" : "Shift Management"} +

+
+
+ +
+ {step === 1 && ( +
+ + +
+
+ + + {errors.event_name &&

{errors.event_name.message}

} +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +