feat: Implement manual assignment for administrators
This commit is contained in:
@@ -1,17 +1,18 @@
|
|||||||
import React, { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock } from "lucide-react";
|
import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock, FileText, UserPlus } from "lucide-react";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
|
||||||
import { Button } from "@/common/components/ui/button";
|
import { Button } from "@/common/components/ui/button";
|
||||||
import { Badge } from "@/common/components/ui/badge";
|
import { Badge } from "@/common/components/ui/badge";
|
||||||
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||||
import { useGetOrderById, useUpdateOrder } from "@/dataconnect-generated/react";
|
import { useGetOrderById, useUpdateOrder, useListShiftRolesByBusinessAndOrder } from "@/dataconnect-generated/react";
|
||||||
import { OrderStatus } from "@/dataconnect-generated";
|
import { OrderStatus } from "@/dataconnect-generated";
|
||||||
import { dataConnect } from "@/features/auth/firebase";
|
import { dataConnect } from "@/features/auth/firebase";
|
||||||
import { useToast } from "@/common/components/ui/use-toast";
|
import { useToast } from "@/common/components/ui/use-toast";
|
||||||
|
import AssignStaffModal from "./components/AssignStaffModal";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
|
|
||||||
const safeFormatDate = (value?: string | null): string => {
|
const safeFormatDate = (value?: string | null): string => {
|
||||||
@@ -85,6 +86,8 @@ export default function OrderDetail() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSelector((state: RootState) => state.auth);
|
const { user } = useSelector((state: RootState) => state.auth);
|
||||||
|
const [selectedShift, setSelectedShift] = useState<any>(null);
|
||||||
|
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -99,6 +102,22 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
const order = data?.order;
|
const order = data?.order;
|
||||||
|
|
||||||
|
// Fetch real shift roles to get IDs and accurate counts
|
||||||
|
const {
|
||||||
|
data: shiftRolesData,
|
||||||
|
isLoading: isLoadingShifts,
|
||||||
|
refetch: refetchShifts
|
||||||
|
} = useListShiftRolesByBusinessAndOrder(
|
||||||
|
dataConnect,
|
||||||
|
{
|
||||||
|
orderId: id || "",
|
||||||
|
businessId: order?.businessId || ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id && !!order?.businessId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const cancelMutation = useUpdateOrder(dataConnect, {
|
const cancelMutation = useUpdateOrder(dataConnect, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
@@ -140,7 +159,12 @@ export default function OrderDetail() {
|
|||||||
navigate(`/orders/create?duplicate=${id}`);
|
navigate(`/orders/create?duplicate=${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shifts: any[] = Array.isArray(order?.shifts) ? (order!.shifts as any[]) : [];
|
const shifts: any[] = useMemo(() => {
|
||||||
|
if (shiftRolesData?.shiftRoles && shiftRolesData.shiftRoles.length > 0) {
|
||||||
|
return shiftRolesData.shiftRoles;
|
||||||
|
}
|
||||||
|
return Array.isArray(order?.shifts) ? (order!.shifts as any[]) : [];
|
||||||
|
}, [shiftRolesData, order?.shifts]);
|
||||||
|
|
||||||
const totalRequested = order?.requested ?? 0;
|
const totalRequested = order?.requested ?? 0;
|
||||||
const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 0;
|
const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 0;
|
||||||
@@ -238,7 +262,7 @@ export default function OrderDetail() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
|
||||||
<FileTextIcon />
|
<FileText className="w-6 h-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
@@ -352,6 +376,21 @@ export default function OrderDetail() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-semibold">{vacancies}</span>
|
<span className="font-semibold">{vacancies}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isClient && canModify && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-primary hover:text-primary hover:bg-primary/10 rounded-lg h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShift(shift);
|
||||||
|
setIsAssignModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-1.5" />
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -431,20 +470,20 @@ export default function OrderDetail() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedShift && (
|
||||||
|
<AssignStaffModal
|
||||||
|
isOpen={isAssignModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsAssignModalOpen(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
onSuccess={() => {
|
||||||
|
refetchShifts();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DashboardLayout>
|
</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>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { Search, Check, X, UserPlus, Star, Clock, AlertTriangle } from "lucide-react";
|
||||||
|
import { format, isSameDay, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/common/components/ui/dialog";
|
||||||
|
import { Button } from "@/common/components/ui/button";
|
||||||
|
import { Input } from "@/common/components/ui/input";
|
||||||
|
import { Badge } from "@/common/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/common/components/ui/avatar";
|
||||||
|
import { useToast } from "@/common/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
useListWorkforceByVendorId,
|
||||||
|
useCreateAssignment,
|
||||||
|
useUpdateShiftRole,
|
||||||
|
useListAssignments,
|
||||||
|
useListStaffAvailabilitiesByDay
|
||||||
|
} from "@/dataconnect-generated/react";
|
||||||
|
import { dataConnect } from "@/features/auth/firebase";
|
||||||
|
import { AssignmentStatus } from "@/dataconnect-generated";
|
||||||
|
|
||||||
|
interface AssignStaffModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shift: any; // The ShiftRole object
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignStaffModal({ isOpen, onClose, shift, onSuccess }: AssignStaffModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedStaff, setSelectedStaff] = useState<any>(null);
|
||||||
|
|
||||||
|
const vendorId = shift.shift?.order?.vendorId || shift.order?.vendorId;
|
||||||
|
const shiftDate = shift.shift?.date || shift.date;
|
||||||
|
const shiftStartTime = shift.startTime || shift.start;
|
||||||
|
const shiftEndTime = shift.endTime || shift.end;
|
||||||
|
|
||||||
|
// Fetch all workforce members for this vendor
|
||||||
|
const { data: workforceData, isLoading: isLoadingWorkforce } = useListWorkforceByVendorId(
|
||||||
|
dataConnect,
|
||||||
|
{ vendorId: vendorId || "" },
|
||||||
|
{ enabled: !!vendorId }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch existing assignments to check for conflicts
|
||||||
|
const { data: assignmentsData } = useListAssignments(dataConnect);
|
||||||
|
|
||||||
|
// Fetch availabilities for the day of the shift
|
||||||
|
// Note: This is simplified. Proper day of week mapping would be needed.
|
||||||
|
// const dayOfWeek = format(new Date(shiftDate), "EEEE").toUpperCase();
|
||||||
|
// const { data: availabilitiesData } = useListStaffAvailabilitiesByDay(
|
||||||
|
// dataConnect,
|
||||||
|
// { day: dayOfWeek as any }
|
||||||
|
// );
|
||||||
|
|
||||||
|
const createAssignmentMutation = useCreateAssignment(dataConnect, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// Also update the shift role's assigned count
|
||||||
|
updateShiftRoleMutation.mutate({
|
||||||
|
shiftId: shift.shiftId,
|
||||||
|
roleId: shift.roleId,
|
||||||
|
assigned: (shift.assigned || 0) + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "Assignment failed",
|
||||||
|
description: "Could not assign staff member. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateShiftRoleMutation = useUpdateShiftRole(dataConnect, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Staff Assigned",
|
||||||
|
description: "The staff member has been successfully assigned to the shift.",
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const staffList = useMemo(() => {
|
||||||
|
if (!workforceData?.workforces) return [];
|
||||||
|
|
||||||
|
return workforceData.workforces.map((w: any) => {
|
||||||
|
const staff = w.staff;
|
||||||
|
const workforceId = w.id;
|
||||||
|
|
||||||
|
// Basic skill matching (check if role name is in staff skills)
|
||||||
|
const roleName = shift.role?.name?.toLowerCase() || "";
|
||||||
|
const hasSkill = staff.skills?.some((s: string) => s.toLowerCase().includes(roleName)) || false;
|
||||||
|
|
||||||
|
// Conflict detection (check if staff is already assigned at this time)
|
||||||
|
const hasConflict = assignmentsData?.assignments?.some((a: any) => {
|
||||||
|
if (a.workforce.staff.id !== staff.id) return false;
|
||||||
|
if (a.status === AssignmentStatus.CANCELED) return false;
|
||||||
|
|
||||||
|
const aStart = new Date(a.shiftRole.startTime);
|
||||||
|
const aEnd = new Date(a.shiftRole.endTime);
|
||||||
|
const sStart = new Date(shiftStartTime);
|
||||||
|
const sEnd = new Date(shiftEndTime);
|
||||||
|
|
||||||
|
// Overlap check
|
||||||
|
return sStart < aEnd && aStart < sEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...staff,
|
||||||
|
workforceId,
|
||||||
|
hasSkill,
|
||||||
|
hasConflict,
|
||||||
|
reliability: staff.reliabilityScore || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [workforceData, assignmentsData, shift, shiftStartTime, shiftEndTime]);
|
||||||
|
|
||||||
|
const filteredStaff = useMemo(() => {
|
||||||
|
return staffList.filter((s: any) =>
|
||||||
|
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
s.email?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
).sort((a: any, b: any) => {
|
||||||
|
// Sort by skill match and then reliability
|
||||||
|
if (a.hasSkill && !b.hasSkill) return -1;
|
||||||
|
if (!a.hasSkill && b.hasSkill) return 1;
|
||||||
|
return b.reliability - a.reliability;
|
||||||
|
});
|
||||||
|
}, [staffList, searchQuery]);
|
||||||
|
|
||||||
|
const handleAssign = () => {
|
||||||
|
if (!selectedStaff) return;
|
||||||
|
|
||||||
|
createAssignmentMutation.mutate({
|
||||||
|
workforceId: selectedStaff.workforceId,
|
||||||
|
shiftId: shift.shiftId,
|
||||||
|
roleId: shift.roleId,
|
||||||
|
status: AssignmentStatus.PENDING,
|
||||||
|
title: `Assignment for ${shift.role?.name || "Shift"}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col p-0 overflow-hidden rounded-2xl border-none shadow-2xl">
|
||||||
|
<DialogHeader className="p-6 pb-2">
|
||||||
|
<DialogTitle className="text-2xl font-bold text-primary-text">Assign Staff</DialogTitle>
|
||||||
|
<DialogDescription className="text-secondary-text">
|
||||||
|
Select a staff member for the <span className="font-bold text-primary">{shift.role?.name || "Shift"}</span> role.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="p-6 pt-2 flex-1 overflow-y-auto space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search staff by name or email..."
|
||||||
|
className="pl-10 rounded-xl border-border/50 bg-slate-50/50"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider text-secondary-text px-1">
|
||||||
|
Available Staff Members
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoadingWorkforce ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 space-y-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">Loading workforce...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredStaff.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center bg-slate-50 rounded-2xl border border-dashed">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No staff members found matching your search.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{filteredStaff.map((staff: any) => (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
onClick={() => !staff.hasConflict && setSelectedStaff(staff)}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-between p-3 rounded-xl border transition-all cursor-pointer
|
||||||
|
${selectedStaff?.id === staff.id
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||||
|
: "border-border/40 hover:border-border hover:bg-slate-50/50"}
|
||||||
|
${staff.hasConflict ? "opacity-60 grayscale cursor-not-allowed" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="w-10 h-10 border-2 border-white shadow-sm">
|
||||||
|
<AvatarImage src={staff.photoUrl} />
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary font-bold">
|
||||||
|
{staff.fullName.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-bold text-primary-text text-sm">{staff.fullName}</p>
|
||||||
|
{staff.hasSkill && (
|
||||||
|
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 hover:bg-emerald-50 text-[9px] h-4 px-1 border-emerald-100">
|
||||||
|
SKILL MATCH
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
|
||||||
|
<Star className="w-3 h-3 text-amber-500 fill-amber-500" />
|
||||||
|
{staff.reliability}% Reliability
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{staff.totalShifts || 0} Shifts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{staff.hasConflict ? (
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<Badge variant="outline" className="text-red-600 bg-red-50 border-red-100 text-[10px]">
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
CONFLICT
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : selectedStaff?.id === staff.id ? (
|
||||||
|
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6 border-2 border-border/50 rounded-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-6 border-t border-border/40 bg-slate-50/50">
|
||||||
|
<Button variant="ghost" onClick={onClose} className="rounded-xl font-bold uppercase text-xs tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAssign}
|
||||||
|
disabled={!selectedStaff || createAssignmentMutation.isPending}
|
||||||
|
className="rounded-xl px-8 font-bold uppercase text-xs tracking-wider shadow-lg shadow-primary/20"
|
||||||
|
>
|
||||||
|
{createAssignmentMutation.isPending ? "Assigning..." : "Confirm Assignment"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user