278 lines
15 KiB
TypeScript
278 lines
15 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Button } from "@/common/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/common/components/ui/dropdown-menu";
|
|
import { Input } from "@/common/components/ui/input";
|
|
import { useToast } from "@/common/components/ui/use-toast";
|
|
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
|
import { DragDropContext, Draggable,type DraggableProvided,type DropResult } from "@hello-pangea/dnd";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { ArrowUpDown, Calendar, Link2, MoreVertical, Palette, Ruler, Search, Users } from "lucide-react";
|
|
import { useListShifts, useUpdateShift, useListBusinesses } from "@/dataconnect-generated/react";
|
|
import { ShiftStatus } from "@/dataconnect-generated";
|
|
import { dataConnect } from "@/features/auth/firebase";
|
|
import TaskCard from "./TaskCard";
|
|
import TaskColumn from "./TaskColumn";
|
|
import { format } from "date-fns";
|
|
|
|
/**
|
|
* TaskBoard Feature Component
|
|
* Kanban board for managing shift assignments and their current status.
|
|
* Optimized for real-time data using Firebase Data Connect.
|
|
*/
|
|
export default function TaskBoard() {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
// UI State
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [filterClient, setFilterClient] = useState("all");
|
|
const [filterDate, setFilterDate] = useState("");
|
|
const [sortBy, setSortBy] = useState("date");
|
|
const [itemHeight, setItemHeight] = useState<"compact" | "normal" | "comfortable">("normal");
|
|
const [conditionalColoring, setConditionalColoring] = useState(true);
|
|
|
|
// Queries
|
|
const { data: shiftsData, isLoading: shiftsLoading } = useListShifts(dataConnect);
|
|
const { data: clientsData } = useListBusinesses(dataConnect);
|
|
|
|
const shifts = useMemo(() => shiftsData?.shifts || [], [shiftsData]);
|
|
const clients = useMemo(() => clientsData?.businesses || [], [clientsData]);
|
|
|
|
// Filtering & Sorting Logic
|
|
const filteredShifts = useMemo(() => {
|
|
let result = [...shifts];
|
|
|
|
if (searchQuery) {
|
|
result = result.filter(s =>
|
|
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.location?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
}
|
|
|
|
if (filterClient !== "all") {
|
|
result = result.filter(s => s.order?.businessId === filterClient);
|
|
}
|
|
|
|
if (filterDate) {
|
|
result = result.filter(s => {
|
|
if (!s.date) return false;
|
|
return format(new Date(s.date), 'yyyy-MM-dd') === filterDate;
|
|
});
|
|
}
|
|
|
|
// Sort
|
|
return result.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case "date":
|
|
return new Date(a.date || '9999-12-31').getTime() - new Date(b.date || '9999-12-31').getTime();
|
|
case "title":
|
|
return (a.title || '').localeCompare(b.title || '');
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
}, [shifts, searchQuery, filterClient, filterDate, sortBy]);
|
|
|
|
// Grouping Logic
|
|
const shiftsByStatus = useMemo<Partial<Record<ShiftStatus, typeof filteredShifts>>>(() => ({
|
|
[ShiftStatus.OPEN]: filteredShifts.filter(s => s.status === ShiftStatus.OPEN),
|
|
[ShiftStatus.PENDING]: filteredShifts.filter(s => s.status === ShiftStatus.PENDING),
|
|
[ShiftStatus.CONFIRMED]: filteredShifts.filter(s => s.status === ShiftStatus.CONFIRMED),
|
|
[ShiftStatus.IN_PROGRESS]: filteredShifts.filter(s => s.status === ShiftStatus.IN_PROGRESS),
|
|
[ShiftStatus.COMPLETED]: filteredShifts.filter(s => s.status === ShiftStatus.COMPLETED),
|
|
}), [filteredShifts]);
|
|
|
|
const overallProgress = useMemo(() => {
|
|
if (shifts.length === 0) return 0;
|
|
const completedCount = shifts.filter(s => s.status === ShiftStatus.COMPLETED).length;
|
|
return Math.round((completedCount / shifts.length) * 100);
|
|
}, [shifts]);
|
|
|
|
// Mutations
|
|
const { mutate: updateShiftStatus } = useUpdateShift(dataConnect, {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['listShifts'] });
|
|
toast({ title: "Status Updated", description: "Shift status has been updated successfully." });
|
|
},
|
|
});
|
|
|
|
// Handlers
|
|
const handleDragEnd = (result: DropResult) => {
|
|
if (!result.destination) return;
|
|
const { source, destination, draggableId } = result;
|
|
if (source.droppableId === destination.droppableId && source.index === destination.index) return;
|
|
|
|
updateShiftStatus({
|
|
id: draggableId,
|
|
status: destination.droppableId as ShiftStatus
|
|
});
|
|
};
|
|
|
|
return (
|
|
<DashboardLayout
|
|
title="Shift Assignment Board"
|
|
subtitle={`${overallProgress}% Global Completion`}
|
|
actions={[
|
|
(
|
|
<Button variant="outline" leadingIcon={<Link2 />}>
|
|
Share Board
|
|
</Button>
|
|
)
|
|
]}
|
|
>
|
|
{/* Page Header */}
|
|
<div className="flex flex-col gap-2">
|
|
{/* Main Toolbar */}
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<Input
|
|
placeholder="Search shifts, events, or locations..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="relative w-full md:w-96"
|
|
leadingIcon={<Search />}
|
|
/>
|
|
|
|
<div className="flex items-center gap-2 flex-1 justify-end">
|
|
<Input
|
|
type="date"
|
|
value={filterDate}
|
|
onChange={(e) => setFilterDate(e.target.value)}
|
|
className="w-44"
|
|
/>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" leadingIcon={<Users />}>
|
|
Clients
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent align="end" className="w-64 rounded-2xl p-2 shadow-2xl border-border/50 ">
|
|
<DropdownMenuLabel className="px-3 pb-2 pt-1 text-[10px] font-bold uppercase text-muted-foreground tracking-widest leading-none">Filter by Client</DropdownMenuLabel>
|
|
<DropdownMenuItem onClick={() => setFilterClient("all")} className="rounded-xl font-medium py-3 px-4 hover:bg-primary/5 transition-colors">All Clients</DropdownMenuItem>
|
|
<DropdownMenuSeparator className="my-1 opacity-40 mx-2" />
|
|
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
|
{clients.map((client) => (
|
|
<DropdownMenuItem
|
|
key={client.id}
|
|
onClick={() => setFilterClient(client.id)}
|
|
className="rounded-xl font-medium px-4 flex items-center gap-3 hover:bg-primary/5 transition-colors"
|
|
>
|
|
<span className="text-sm">{client.businessName}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" leadingIcon={<ArrowUpDown />}>
|
|
Sort
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64 rounded-2xl p-2 shadow-2xl border-border/50 ">
|
|
<DropdownMenuLabel className="text-[10px] font-bold uppercase text-muted-foreground tracking-widest">Ordering Options</DropdownMenuLabel>
|
|
<DropdownMenuItem onClick={() => setSortBy("date")} className="rounded-xl font-medium flex items-center justify-between">Date <Calendar className="w-3.5 h-3.5 opacity-40" /></DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setSortBy("title")} className="rounded-xl font-medium flex items-center justify-between">Title <Ruler className="w-3.5 h-3.5 opacity-40" /></DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div className="w-px h-8 bg-border/40 mx-1" />
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" leadingIcon={<MoreVertical />}>
|
|
View Options
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64 rounded-2xl p-2 shadow-2xl border-border/50 ">
|
|
<DropdownMenuLabel className="text-[10px] font-bold uppercase text-muted-foreground tracking-widest">Display Settings</DropdownMenuLabel>
|
|
<DropdownMenuItem onClick={() => setItemHeight("compact")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"><Ruler className="w-4 h-4 opacity-40" /> Compact Cards</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setItemHeight("normal")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"><Ruler className="w-4 h-4 opacity-40" /> Normal Layout</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setItemHeight("comfortable")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"><Ruler className="w-4 h-4 opacity-40" /> Comfort View</DropdownMenuItem>
|
|
<DropdownMenuSeparator className="my-1 opacity-40 mx-2" />
|
|
<DropdownMenuItem onClick={() => setConditionalColoring(!conditionalColoring)} className="rounded-xl font-medium gap-3 hover:bg-primary/5">
|
|
<Palette className={`w-4 h-4 ${conditionalColoring ? 'text-primary' : 'opacity-40'}`} />
|
|
{conditionalColoring ? 'Minimalist Mode' : 'Enhanced Visuals'}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Overall Progress */}
|
|
<div className="my-8 flex items-center gap-8 bg-primary/5 p-6 rounded-2xl border border-primary/10">
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.1em]">Global Completion</span>
|
|
<span className="text-xl font-medium text-primary tabular-nums tracking-tight">{overallProgress}%</span>
|
|
</div>
|
|
<div className="h-2 bg-primary/10 rounded-full overflow-hidden shadow-inner">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-1000 ease-in-out shadow-[0_0_15px_rgba(var(--primary),0.3)]"
|
|
style={{ width: `${overallProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-8">
|
|
{/* Kanban Area */}
|
|
<DragDropContext onDragEnd={handleDragEnd}>
|
|
<div className="flex gap-6 overflow-x-auto pb-12 snap-x px-4 -mx-4">
|
|
{[
|
|
{ id: ShiftStatus.OPEN, title: "Unassigned" },
|
|
{ id: ShiftStatus.PENDING, title: "Pending Acceptance" },
|
|
{ id: ShiftStatus.CONFIRMED, title: "Confirmed" },
|
|
{ id: ShiftStatus.IN_PROGRESS, title: "In Progress" },
|
|
{ id: ShiftStatus.COMPLETED, title: "Completed" },
|
|
].map((column) => (
|
|
<TaskColumn
|
|
key={column.id}
|
|
status={column.id}
|
|
title={column.title}
|
|
tasks={shiftsByStatus[column.id] || []}
|
|
>
|
|
{(shiftsByStatus[column.id] || []).map((shift, index) => (
|
|
<Draggable key={shift.id} draggableId={shift.id} index={index}>
|
|
{(provided: DraggableProvided) => (
|
|
<TaskCard
|
|
task={shift as any}
|
|
provided={provided}
|
|
onClick={() => {}}
|
|
itemHeight={itemHeight}
|
|
conditionalColoring={conditionalColoring}
|
|
/>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
</TaskColumn>
|
|
))}
|
|
</div>
|
|
</DragDropContext>
|
|
|
|
{filteredShifts.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-32 bg-white/50 rounded-[2.5rem] border border-dashed border-border/50 text-center animate-premium-fade-in ">
|
|
<div className="w-24 h-24 bg-primary/5 rounded-[2rem] flex items-center justify-center mb-8 shadow-inner border border-primary/10">
|
|
<Calendar className="w-12 h-12 text-primary/20" />
|
|
</div>
|
|
<h3 className="text-2xl font-medium text-foreground mb-4 tracking-tight">No shifts found</h3>
|
|
<p className="text-muted-foreground font-medium max-w-sm mx-auto mb-10 leading-relaxed text-sm">No shifts matching your current filters. Adjust your search or filters to see more.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</DashboardLayout>
|
|
);
|
|
}
|