feat: Implement a Kanban-Style task board
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user