feat: Implement a Kanban-Style task board

This commit is contained in:
dhinesh-m24
2026-02-09 16:12:03 +05:30
parent 0db8cf2d89
commit 3a993c1ca2
7 changed files with 713 additions and 0 deletions

View File

@@ -12,8 +12,10 @@
"dependencies": {
"@firebase/analytics": "^0.10.19",
"@firebase/data-connect": "^0.3.12",
"@hello-pangea/dnd": "^18.0.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",

View File

@@ -17,12 +17,18 @@ importers:
'@firebase/data-connect':
specifier: ^0.3.12
version: 0.3.12(@firebase/app@0.14.7)
'@hello-pangea/dnd':
specifier: ^18.0.1
version: 18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-avatar':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -698,6 +704,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hello-pangea/dnd@18.0.1':
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -2022,6 +2034,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -2655,6 +2670,9 @@ packages:
'@types/react-dom':
optional: true
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
react-datepicker@9.1.0:
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
peerDependencies:
@@ -3642,6 +3660,18 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@hello-pangea/dnd@18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
css-box-model: 1.2.1
raf-schd: 4.0.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
redux: 5.0.1
transitivePeerDependencies:
- '@types/react'
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -4963,6 +4993,10 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-box-model@1.2.1:
dependencies:
tiny-invariant: 1.3.3
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -5618,6 +5652,8 @@ snapshots:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
raf-schd@4.0.3: {}
react-datepicker@9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroups = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Dot className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName =
DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
function DropdownMenuShortcut({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroups as DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,277 @@
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>
);
}

View File

@@ -0,0 +1,120 @@
import { Card } from "@/common/components/ui/card";
import { Badge } from "@/common/components/ui/badge";
import { MoreVertical, Calendar, MapPin, Building2, Users } from "lucide-react";
import { format } from "date-fns";
import {type DraggableProvided } from "@hello-pangea/dnd";
import { ShiftStatus } from "@/dataconnect-generated";
interface TaskCardProps {
task: any; // Using any for Shift data from Data Connect
provided?: DraggableProvided;
onClick: () => void;
itemHeight?: "compact" | "normal" | "comfortable";
conditionalColoring?: boolean;
}
const statusConfig: Record<string, { bg: string; border: string; text: string; label: string }> = {
[ShiftStatus.OPEN]: { bg: "bg-slate-500/10", border: "border-slate-500/20", text: "text-slate-700", label: "Open" },
[ShiftStatus.PENDING]: { bg: "bg-blue-500/10", border: "border-blue-500/20", text: "text-blue-700", label: "Pending" },
[ShiftStatus.CONFIRMED]: { bg: "bg-emerald-500/10", border: "border-emerald-500/20", text: "text-emerald-700", label: "Confirmed" },
[ShiftStatus.IN_PROGRESS]: { bg: "bg-amber-500/10", border: "border-amber-500/20", text: "text-amber-700", label: "In Progress" },
[ShiftStatus.COMPLETED]: { bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-700", label: "Completed" }
};
/**
* TaskCard Component refactored for Shifts
* Renders a single shift card in the Kanban board.
*/
export default function TaskCard({
task,
provided,
onClick,
itemHeight = "normal",
conditionalColoring = true
}: TaskCardProps) {
const heightClasses = {
compact: "p-3",
normal: "p-5",
comfortable: "p-6"
};
const cardPadding = heightClasses[itemHeight] || heightClasses.normal;
const status = statusConfig[task.status] || statusConfig[ShiftStatus.OPEN];
const staffingProgress = task.workersNeeded > 0 ? (task.filled / task.workersNeeded) * 100 : 0;
const progressColor = staffingProgress === 100 ? "bg-emerald-500" : "bg-primary";
return (
<Card
ref={provided?.innerRef}
{...provided?.draggableProps}
{...provided?.dragHandleProps}
onClick={onClick}
className={`bg-white border border-border/50 hover:shadow-xl hover:border-primary/20 transition-premium cursor-pointer mb-3 rounded-2xl group`}
>
<div className={cardPadding}>
{/* Client & Header */}
<div className="flex flex-col gap-1 mb-3">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-primary uppercase tracking-wider">
<Building2 className="w-3 h-3" />
{task.order?.business?.businessName || "No Client"}
</div>
<div className="flex items-start justify-between">
<h4 className="font-medium text-foreground text-sm flex-1 tracking-tight leading-snug group-hover:text-primary transition-colors">
{task.title}
</h4>
<button className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
{/* Event Name */}
{task.order?.eventName && (
<p className="text-[11px] text-muted-foreground mb-4 font-medium italic">
{task.order.eventName}
</p>
)}
{/* Status & Date */}
<div className="flex items-center justify-between mb-4">
<Badge className={`${status.bg} ${status.text} ${status.border} text-[10px] font-medium uppercase tracking-wider px-2 py-0.5 border shadow-none ring-0`}>
{status.label}
</Badge>
{task.date && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground font-medium">
<Calendar className="w-3.5 h-3.5 text-primary/60" />
{format(new Date(task.date), 'd MMM yyyy')}
</div>
)}
</div>
{/* Staffing Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
<Users className="w-3 h-3" />
Staffing: {task.filled}/{task.workersNeeded}
</div>
<span className="text-[10px] font-medium text-muted-foreground tabular-nums">{Math.round(staffingProgress)}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full ${progressColor} transition-all duration-700 ease-out`}
style={{ width: `${staffingProgress}%` }}
/>
</div>
</div>
{/* Footer: Location */}
{task.location && (
<div className="flex items-center gap-1.5 pt-3 border-t border-border/40 text-[11px] text-muted-foreground font-medium">
<MapPin className="w-3.5 h-3.5 text-primary/60" />
<span className="truncate">{task.location}</span>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
import React from "react";
import { Badge } from "@/common/components/ui/badge";
import { Plus, MoreVertical } from "lucide-react";
import { Droppable,type DroppableProvided,type DroppableStateSnapshot } from "@hello-pangea/dnd";
import { ShiftStatus } from "@/dataconnect-generated";
interface TaskColumnProps {
status: string;
title?: string;
tasks: any[];
children: React.ReactNode;
onAddTask?: (status: string) => void;
}
const columnConfig: Record<string, { bg: string; text: string; border: string; label: string; dot: string }> = {
[ShiftStatus.OPEN]: { bg: "bg-slate-500/10", text: "text-slate-700", border: "border-slate-500/20", label: "Unassigned", dot: "bg-slate-500" },
[ShiftStatus.PENDING]: { bg: "bg-blue-500/10", text: "text-blue-700", border: "border-blue-500/20", label: "Pending", dot: "bg-blue-500" },
[ShiftStatus.CONFIRMED]: { bg: "bg-emerald-500/10", text: "text-emerald-700", border: "border-emerald-500/20", label: "Confirmed", dot: "bg-emerald-500" },
[ShiftStatus.IN_PROGRESS]: { bg: "bg-amber-500/10", text: "text-amber-700", border: "border-amber-500/20", label: "In Progress", dot: "bg-amber-500" },
[ShiftStatus.COMPLETED]: { bg: "bg-green-500/10", text: "text-green-700", border: "border-green-500/20", label: "Completed", dot: "bg-green-500" }
};
/**
* TaskColumn Component
* Renders a Kanban column with a droppable area for Shifts.
*/
export default function TaskColumn({ status, title, tasks, children, onAddTask }: TaskColumnProps) {
const config = columnConfig[status] || { bg: "bg-gray-500/10", text: "text-gray-700", border: "border-gray-500/20", label: title || status, dot: "bg-gray-500" };
return (
<div className="flex-1 min-w-[320px] flex flex-col h-full group/column">
{/* Column Header */}
<div className={`${config.bg} ${config.text} ${config.border} border rounded-t-2xl px-5 py-4 flex items-center justify-between shadow-sm transition-premium`}>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${config.dot} shadow-[0_0_8px_rgba(var(--primary),0.4)]`} />
<span className="font-bold uppercase tracking-widest text-[11px]">{title || config.label}</span>
<Badge variant="outline" className="bg-white/50 text-current border-current/20 font-medium px-2 py-0 h-5 text-[10px] shadow-sm">
{tasks.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
{onAddTask && (
<button
onClick={() => onAddTask(status)}
className="w-8 h-8 hover:bg-white/50 rounded-xl flex items-center justify-center transition-premium active:scale-90 border border-transparent hover:border-current/20"
>
<Plus className="w-4 h-4" />
</button>
)}
<button className="w-8 h-8 hover:bg-white/50 rounded-xl flex items-center justify-center transition-premium border border-transparent hover:border-current/20">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
{/* Droppable Area */}
<Droppable droppableId={status}>
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex-1 min-h-[500px] rounded-b-2xl p-4 transition-premium ${snapshot.isDraggingOver
? 'bg-primary/5 border-x-2 border-b-2 border-dashed border-primary/20 shadow-inner'
: 'bg-muted/[0.15] border-x border-b border-border/50'
}`}
>
<div className="space-y-1">
{children}
</div>
{provided.placeholder}
</div>
)}
</Droppable>
</div>
);
}

View File

@@ -23,6 +23,8 @@ import VendorOrderList from './features/operations/orders/VendorOrderList';
import EditOrder from './features/operations/orders/EditOrder';
import Schedule from './features/operations/schedule/Schedule';
import StaffAvailability from './features/operations/availability/StaffAvailability';
import TaskBoard from './features/operations/tasks/TaskBoard';
/**
* AppRoutes Component
@@ -108,6 +110,8 @@ const AppRoutes: React.FC = () => {
<Route path="/schedule" element={<Schedule />} />
<Route path='/availability' element={<StaffAvailability />} />
<Route path="/tasks" element={<TaskBoard />} />
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />