feat: Implement a Kanban-Style task board
This commit is contained in:
@@ -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",
|
||||
|
||||
36
apps/web/pnpm-lock.yaml
generated
36
apps/web/pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
198
apps/web/src/common/components/ui/dropdown-menu.tsx
Normal file
198
apps/web/src/common/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
277
apps/web/src/features/operations/tasks/TaskBoard.tsx
Normal file
277
apps/web/src/features/operations/tasks/TaskBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/features/operations/tasks/TaskCard.tsx
Normal file
120
apps/web/src/features/operations/tasks/TaskCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/features/operations/tasks/TaskColumn.tsx
Normal file
76
apps/web/src/features/operations/tasks/TaskColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user