feat: Implement manual assignment for administrators

This commit is contained in:
dhinesh-m24
2026-02-09 15:20:32 +05:30
parent f52ee4583a
commit 0db8cf2d89
2 changed files with 325 additions and 19 deletions

View File

@@ -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>
);

View File

@@ -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>
);
}