Files
Krow-workspace/apps/web/src/features/operations/tasks/TaskBoard.tsx
2026-02-09 16:12:03 +05:30

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