From 3a993c1ca2b898675b78c83df6974b160fe32512 Mon Sep 17 00:00:00 2001 From: dhinesh-m24 Date: Mon, 9 Feb 2026 16:12:03 +0530 Subject: [PATCH] feat: Implement a Kanban-Style task board --- apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 36 +++ .../common/components/ui/dropdown-menu.tsx | 198 +++++++++++++ .../features/operations/tasks/TaskBoard.tsx | 277 ++++++++++++++++++ .../features/operations/tasks/TaskCard.tsx | 120 ++++++++ .../features/operations/tasks/TaskColumn.tsx | 76 +++++ apps/web/src/routes.tsx | 4 + 7 files changed, 713 insertions(+) create mode 100644 apps/web/src/common/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/features/operations/tasks/TaskBoard.tsx create mode 100644 apps/web/src/features/operations/tasks/TaskCard.tsx create mode 100644 apps/web/src/features/operations/tasks/TaskColumn.tsx diff --git a/apps/web/package.json b/apps/web/package.json index e952afcd..3016c650 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 5f4687dc..41875017 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -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) diff --git a/apps/web/src/common/components/ui/dropdown-menu.tsx b/apps/web/src/common/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..a97a3995 --- /dev/null +++ b/apps/web/src/common/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = + DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +function DropdownMenuShortcut({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroups as DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/web/src/features/operations/tasks/TaskBoard.tsx b/apps/web/src/features/operations/tasks/TaskBoard.tsx new file mode 100644 index 00000000..f95e146a --- /dev/null +++ b/apps/web/src/features/operations/tasks/TaskBoard.tsx @@ -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>>(() => ({ + [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 ( + }> + Share Board + + ) + ]} + > + {/* Page Header */} +
+ {/* Main Toolbar */} +
+ setSearchQuery(e.target.value)} + className="relative w-full md:w-96" + leadingIcon={} + /> + +
+ setFilterDate(e.target.value)} + className="w-44" + /> + + + + + + + + Filter by Client + setFilterClient("all")} className="rounded-xl font-medium py-3 px-4 hover:bg-primary/5 transition-colors">All Clients + +
+ {clients.map((client) => ( + setFilterClient(client.id)} + className="rounded-xl font-medium px-4 flex items-center gap-3 hover:bg-primary/5 transition-colors" + > + {client.businessName} + + ))} +
+
+
+ + + + + + + Ordering Options + setSortBy("date")} className="rounded-xl font-medium flex items-center justify-between">Date + setSortBy("title")} className="rounded-xl font-medium flex items-center justify-between">Title + + + +
+ + + + + + + Display Settings + setItemHeight("compact")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"> Compact Cards + setItemHeight("normal")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"> Normal Layout + setItemHeight("comfortable")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"> Comfort View + + setConditionalColoring(!conditionalColoring)} className="rounded-xl font-medium gap-3 hover:bg-primary/5"> + + {conditionalColoring ? 'Minimalist Mode' : 'Enhanced Visuals'} + + + +
+
+ + {/* Overall Progress */} +
+
+
+ Global Completion + {overallProgress}% +
+
+
+
+
+
+
+ +
+ {/* Kanban Area */} + +
+ {[ + { 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) => ( + + {(shiftsByStatus[column.id] || []).map((shift, index) => ( + + {(provided: DraggableProvided) => ( + {}} + itemHeight={itemHeight} + conditionalColoring={conditionalColoring} + /> + )} + + ))} + + ))} +
+
+ + {filteredShifts.length === 0 && ( +
+
+ +
+

No shifts found

+

No shifts matching your current filters. Adjust your search or filters to see more.

+
+ )} +
+ + + ); +} diff --git a/apps/web/src/features/operations/tasks/TaskCard.tsx b/apps/web/src/features/operations/tasks/TaskCard.tsx new file mode 100644 index 00000000..47bb7c3f --- /dev/null +++ b/apps/web/src/features/operations/tasks/TaskCard.tsx @@ -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 = { + [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 ( + +
+ {/* Client & Header */} +
+
+ + {task.order?.business?.businessName || "No Client"} +
+
+

+ {task.title} +

+ +
+
+ + {/* Event Name */} + {task.order?.eventName && ( +

+ {task.order.eventName} +

+ )} + + {/* Status & Date */} +
+ + {status.label} + + {task.date && ( +
+ + {format(new Date(task.date), 'd MMM yyyy')} +
+ )} +
+ + {/* Staffing Progress */} +
+
+
+ + Staffing: {task.filled}/{task.workersNeeded} +
+ {Math.round(staffingProgress)}% +
+
+
+
+
+ + {/* Footer: Location */} + {task.location && ( +
+ + {task.location} +
+ )} +
+ + ); +} diff --git a/apps/web/src/features/operations/tasks/TaskColumn.tsx b/apps/web/src/features/operations/tasks/TaskColumn.tsx new file mode 100644 index 00000000..a2904b2b --- /dev/null +++ b/apps/web/src/features/operations/tasks/TaskColumn.tsx @@ -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 = { + [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 ( +
+ {/* Column Header */} +
+
+
+ {title || config.label} + + {tasks.length} + +
+
+ {onAddTask && ( + + )} + +
+
+ + {/* Droppable Area */} + + {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( +
+
+ {children} +
+ {provided.placeholder} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index a344f9b1..5032bac5 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -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 = () => { } /> } /> + + } /> } />