feat: Implement calendar view to manage orders and events
This commit is contained in:
487
apps/web/src/features/operations/schedule/Schedule.tsx
Normal file
487
apps/web/src/features/operations/schedule/Schedule.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
addDays,
|
||||
addWeeks,
|
||||
addMonths,
|
||||
format,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isValid,
|
||||
parseISO,
|
||||
startOfWeek,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
subWeeks,
|
||||
subMonths,
|
||||
subDays
|
||||
} from "date-fns";
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
Users,
|
||||
AlertTriangle
|
||||
} from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "@/common/components/ui/badge";
|
||||
import { Button } from "@/common/components/ui/button";
|
||||
import { Card, CardContent } from "@/common/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from "@/common/components/ui/dialog";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/common/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/common/components/ui/tabs";
|
||||
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||
import { useListOrders, useUpdateOrder } from "@/dataconnect-generated/react";
|
||||
import { dataConnect } from "@/features/auth/firebase";
|
||||
import { OrderStatus } from "@/dataconnect-generated";
|
||||
import { useToast } from "@/common/components/ui/use-toast";
|
||||
|
||||
/**
|
||||
* Maps OrderStatus to appropriate Tailwind color classes
|
||||
*/
|
||||
const getStatusColor = (status: OrderStatus | undefined) => {
|
||||
switch (status) {
|
||||
case OrderStatus.DRAFT:
|
||||
case OrderStatus.CANCELLED:
|
||||
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||
case OrderStatus.FILLED:
|
||||
case OrderStatus.FULLY_STAFFED:
|
||||
case OrderStatus.COMPLETED:
|
||||
return "bg-emerald-50 text-emerald-700 border-emerald-200";
|
||||
case OrderStatus.POSTED:
|
||||
case OrderStatus.PENDING:
|
||||
case OrderStatus.PARTIAL_STAFFED:
|
||||
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
default:
|
||||
return "bg-slate-50 text-slate-600 border-slate-200";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely parses various date formats into a Date object
|
||||
*/
|
||||
const safeParseDate = (dateString: any) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Schedule() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// State for rescheduling confirmation
|
||||
const [rescheduleData, setRescheduleData] = useState<{ order: any; newDate: Date } | null>(null);
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
|
||||
// Fetch real data from Data Connect
|
||||
const { data, isLoading } = useListOrders(dataConnect);
|
||||
const orders = data?.orders || [];
|
||||
|
||||
// Mutation for drag-and-drop rescheduling
|
||||
const updateOrderMutation = useUpdateOrder(dataConnect, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listOrders"] });
|
||||
toast({
|
||||
title: "Order Rescheduled",
|
||||
description: "The order date has been updated successfully.",
|
||||
});
|
||||
setIsConfirmOpen(false);
|
||||
setRescheduleData(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Update Failed",
|
||||
description: "Could not reschedule the order. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate days to display based on current view mode
|
||||
const calendarDays = useMemo(() => {
|
||||
if (viewMode === 'month') {
|
||||
const start = startOfWeek(startOfMonth(currentDate));
|
||||
const end = endOfWeek(endOfMonth(currentDate));
|
||||
return eachDayOfInterval({ start, end });
|
||||
} else if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
return eachDayOfInterval({ start, end });
|
||||
} else {
|
||||
return [currentDate];
|
||||
}
|
||||
}, [currentDate, viewMode]);
|
||||
|
||||
const getOrdersForDay = (date: Date) => {
|
||||
return orders.filter((order) => {
|
||||
const orderDate = safeParseDate(order.date);
|
||||
return orderDate && isSameDay(orderDate, date);
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate metrics for the current visible range
|
||||
const metrics = useMemo(() => {
|
||||
const visibleOrders = orders.filter(order => {
|
||||
const orderDate = safeParseDate(order.date);
|
||||
if (!orderDate) return false;
|
||||
return orderDate >= calendarDays[0] && orderDate <= calendarDays[calendarDays.length - 1];
|
||||
});
|
||||
|
||||
const totalHours = visibleOrders.reduce((sum, order) => {
|
||||
const shifts = Array.isArray(order.shifts) ? order.shifts : [];
|
||||
const orderHours = shifts.reduce((shiftSum: number, shift: any) => {
|
||||
const roles = Array.isArray(shift.roles) ? shift.roles : [];
|
||||
return shiftSum + roles.reduce((roleSum: number, role: any) => roleSum + (Number(role.hours) || 0), 0);
|
||||
}, 0);
|
||||
return sum + orderHours;
|
||||
}, 0);
|
||||
|
||||
const totalCost = visibleOrders.reduce((sum, order) => sum + (order.total || 0), 0);
|
||||
const totalShifts = visibleOrders.reduce((sum, order) => sum + (Array.isArray(order.shifts) ? order.shifts.length : 0), 0);
|
||||
|
||||
return { totalHours, totalCost, totalShifts };
|
||||
}, [orders, calendarDays]);
|
||||
|
||||
// Navigation handlers
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'month') setCurrentDate(subMonths(currentDate, 1));
|
||||
else if (viewMode === 'week') setCurrentDate(subWeeks(currentDate, 1));
|
||||
else setCurrentDate(subDays(currentDate, 1));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'month') setCurrentDate(addMonths(currentDate, 1));
|
||||
else if (viewMode === 'week') setCurrentDate(addWeeks(currentDate, 1));
|
||||
else setCurrentDate(addDays(currentDate, 1));
|
||||
};
|
||||
|
||||
const handleToday = () => setCurrentDate(new Date());
|
||||
|
||||
const handleEventClick = (order: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedOrder(order);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Drag and Drop Logic
|
||||
const onDragStart = (e: React.DragEvent, order: any) => {
|
||||
e.dataTransfer.setData("orderId", order.id);
|
||||
};
|
||||
|
||||
const onDrop = async (e: React.DragEvent, date: Date) => {
|
||||
e.preventDefault();
|
||||
const orderId = e.dataTransfer.getData("orderId");
|
||||
const order = orders.find(o => o.id === orderId);
|
||||
|
||||
if (order && !isSameDay(safeParseDate(order.date)!, date)) {
|
||||
setRescheduleData({ order, newDate: date });
|
||||
setIsConfirmOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReschedule = () => {
|
||||
if (rescheduleData) {
|
||||
updateOrderMutation.mutate({
|
||||
id: rescheduleData.order.id,
|
||||
date: rescheduleData.newDate.toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Schedule" subtitle="Loading shifts...">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Schedule"
|
||||
subtitle="Plan and manage staff shifts"
|
||||
actions={
|
||||
<Button onClick={() => navigate('/orders/new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Order
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Metrics Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-white border-border/50 shadow-sm">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Total Hours</p>
|
||||
<p className="text-2xl font-bold text-primary-text">{metrics.totalHours.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 rounded-xl text-blue-600">
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white border-border/50 shadow-sm">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Labor Cost</p>
|
||||
<p className="text-2xl font-bold text-primary-text">${metrics.totalCost.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-50 rounded-xl text-emerald-600">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white border-border/50 shadow-sm">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Total Shifts</p>
|
||||
<p className="text-2xl font-bold text-primary-text">{metrics.totalShifts}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-teal-50 rounded-xl text-teal-600">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Calendar Controls */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-4 rounded-xl border border-border/50 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={handlePrev} className="h-9 w-9">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleToday} className="h-9 px-4 text-xs font-bold uppercase tracking-wider">
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleNext} className="h-9 w-9">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<h2 className="ml-4 text-lg font-bold text-primary-text">
|
||||
{format(currentDate, viewMode === 'month' ? 'MMMM yyyy' : 'MMMM d, yyyy')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)} className="w-full md:w-auto">
|
||||
<TabsList className="grid grid-cols-3 w-full md:w-[240px]">
|
||||
<TabsTrigger value="day" className="text-xs font-bold uppercase tracking-wider">Day</TabsTrigger>
|
||||
<TabsTrigger value="week" className="text-xs font-bold uppercase tracking-wider">Week</TabsTrigger>
|
||||
<TabsTrigger value="month" className="text-xs font-bold uppercase tracking-wider">Month</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className={`grid gap-4 ${
|
||||
viewMode === 'month' ? 'grid-cols-7' :
|
||||
viewMode === 'week' ? 'grid-cols-1 md:grid-cols-7' :
|
||||
'grid-cols-1'
|
||||
}`}>
|
||||
{/* Day Names */}
|
||||
{(viewMode === 'month' || (viewMode === 'week' && window.innerWidth > 768)) &&
|
||||
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="text-center py-2 text-[10px] font-bold uppercase tracking-widest text-secondary-text">
|
||||
{day}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
{calendarDays.map((day) => {
|
||||
const dayOrders = getOrdersForDay(day);
|
||||
const isTodayDay = isToday(day);
|
||||
const isDifferentMonth = viewMode === 'month' && day.getMonth() !== currentDate.getMonth();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, day)}
|
||||
className={`min-h-[140px] flex flex-col p-2 rounded-xl border transition-all ${
|
||||
isTodayDay ? 'bg-primary/5 border-primary ring-1 ring-primary/20' :
|
||||
isDifferentMonth ? 'bg-slate-50/50 border-border/30 opacity-60' :
|
||||
'bg-white border-border/50 hover:border-border hover:shadow-sm'
|
||||
} ${viewMode === 'day' ? 'min-h-[500px]' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className={`text-sm font-bold ${isTodayDay ? 'text-primary' : 'text-primary-text'}`}>
|
||||
{format(day, viewMode === 'day' ? 'EEEE, MMMM d' : 'd')}
|
||||
</span>
|
||||
{dayOrders.length > 0 && (
|
||||
<span className="text-[10px] font-bold text-secondary-text bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{dayOrders.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1 overflow-y-auto max-h-[300px] scrollbar-hide">
|
||||
{dayOrders.map((order: any) => (
|
||||
<div
|
||||
key={order.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, order)}
|
||||
onClick={(e) => handleEventClick(order, e)}
|
||||
className={`p-2 rounded-lg border text-left cursor-pointer transition-all hover:scale-[1.02] active:scale-95 shadow-sm ${getStatusColor(order.status)}`}
|
||||
>
|
||||
<p className="text-[11px] font-bold truncate leading-tight mb-1">
|
||||
{order.eventName || "Unnamed Order"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 opacity-80">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="text-[9px] font-medium">
|
||||
{order.shifts?.[0]?.roles?.[0]?.start_time || "00:00"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dayOrders.length === 0 && viewMode !== 'month' && (
|
||||
<div className="flex-1 flex items-center justify-center opacity-30 border border-dashed rounded-lg py-8">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider">No Orders</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Detail Modal */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle className="text-xl font-bold">{selectedOrder?.eventName || "Order Details"}</DialogTitle>
|
||||
<Badge className={getStatusColor(selectedOrder?.status)}>
|
||||
{selectedOrder?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedOrder && (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Date</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium">{format(safeParseDate(selectedOrder.date)!, 'PPPP')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Total Value</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-sm font-bold text-emerald-700">${selectedOrder.total?.toLocaleString() || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Business</p>
|
||||
<div className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg border border-slate-100">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium">{selectedOrder.business?.businessName || "Krow Workforce"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Shifts & Staffing</p>
|
||||
<div className="space-y-2">
|
||||
{Array.isArray(selectedOrder.shifts) && selectedOrder.shifts.map((shift: any, i: number) => (
|
||||
<div key={i} className="p-3 rounded-lg border border-border/60 bg-white shadow-sm flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-xs font-bold">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-primary-text">{shift.shiftName || `Shift ${i+1}`}</p>
|
||||
<p className="text-[10px] text-secondary-text font-medium">
|
||||
{shift.roles?.[0]?.start_time} - {shift.roles?.[0]?.end_time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] font-bold uppercase tracking-wider">
|
||||
{Array.isArray(shift.assignedStaff) ? shift.assignedStaff.length : 0} Assigned
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => navigate(`/orders/${selectedOrder?.id}`)} className="w-full sm:w-auto">
|
||||
View Full Details
|
||||
</Button>
|
||||
<Button onClick={() => navigate(`/orders/${selectedOrder?.id}/edit`)} className="w-full sm:w-auto">
|
||||
Edit Order
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reschedule Confirmation Dialog */}
|
||||
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Reschedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to move this order to a different date?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{rescheduleData && (
|
||||
<div className="py-4">
|
||||
<Alert variant="default" className="bg-amber-50 border-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
<AlertTitle className="text-amber-800">Confirm Date Change</AlertTitle>
|
||||
<AlertDescription className="text-amber-700">
|
||||
Moving <strong>{rescheduleData.order.eventName}</strong> to <strong>{format(rescheduleData.newDate, 'PPPP')}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmReschedule}
|
||||
disabled={updateOrderMutation.isPending}
|
||||
>
|
||||
{updateOrderMutation.isPending ? "Updating..." : "Confirm Move"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import OrderDetail from './features/operations/orders/OrderDetail';
|
||||
import ClientOrderList from './features/operations/orders/ClientOrderList';
|
||||
import VendorOrderList from './features/operations/orders/VendorOrderList';
|
||||
import EditOrder from './features/operations/orders/EditOrder';
|
||||
import Schedule from './features/operations/schedule/Schedule';
|
||||
|
||||
/**
|
||||
* AppRoutes Component
|
||||
@@ -101,6 +102,10 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/orders/vendor" element={<VendorOrderList />} />
|
||||
<Route path="/orders/:id/edit" element={<EditOrder />} />
|
||||
|
||||
<Route path="/schedule" element={<Schedule />} />
|
||||
|
||||
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
Reference in New Issue
Block a user