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 { format } from "date-fns";
|
||||
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 { 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 { useGetOrderById, useUpdateOrder, useListShiftRolesByBusinessAndOrder } from "@/dataconnect-generated/react";
|
||||
import { OrderStatus } from "@/dataconnect-generated";
|
||||
import { dataConnect } from "@/features/auth/firebase";
|
||||
import { useToast } from "@/common/components/ui/use-toast";
|
||||
import AssignStaffModal from "./components/AssignStaffModal";
|
||||
import type { RootState } from "@/store/store";
|
||||
|
||||
const safeFormatDate = (value?: string | null): string => {
|
||||
@@ -85,6 +86,8 @@ export default function OrderDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const [selectedShift, setSelectedShift] = useState<any>(null);
|
||||
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -99,6 +102,22 @@ export default function OrderDetail() {
|
||||
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
@@ -140,7 +159,12 @@ export default function OrderDetail() {
|
||||
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 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="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 />
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||
@@ -352,6 +376,21 @@ export default function OrderDetail() {
|
||||
</span>
|
||||
<span className="font-semibold">{vacancies}</span>
|
||||
</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>
|
||||
);
|
||||
@@ -431,20 +470,20 @@ export default function OrderDetail() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{selectedShift && (
|
||||
<AssignStaffModal
|
||||
isOpen={isAssignModalOpen}
|
||||
onClose={() => {
|
||||
setIsAssignModalOpen(false);
|
||||
setSelectedShift(null);
|
||||
}}
|
||||
shift={selectedShift}
|
||||
onSuccess={() => {
|
||||
refetchShifts();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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