Files
GSB-Construction/Modules/TaskManagement/resources/js/Components/KanbanBoard.tsx

184 lines
9.9 KiB
TypeScript

import React, { useMemo, useState, useEffect } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import { Card, CardContent } from '@/Components/ui/card';
import { Badge } from '@/Components/ui/badge';
import { Avatar, AvatarFallback } from '@/Components/ui/avatar';
import { DollarSign, Clock, Users } from 'lucide-react';
interface KanbanBoardProps {
tasks: any[];
onReorder: (reorderedTasks: any[]) => void;
onTaskClick: (taskUlid: string) => void;
}
const COLUMNS = [
{ id: 'pending', title: 'Pending' },
{ id: 'in_progress', title: 'In Progress' },
{ id: 'completed', title: 'Completed' },
{ id: 'blocked', title: 'Blocked' }
];
export default function KanbanBoard({ tasks, onReorder, onTaskClick }: KanbanBoardProps) {
const [localTasks, setLocalTasks] = useState(tasks || []);
useEffect(() => {
setLocalTasks(tasks || []);
}, [tasks]);
const formatCurrency = (v: string | number) =>
new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
// Group tasks by status and sort them
const columnsData = useMemo(() => {
const data: Record<string, any[]> = {
pending: [],
in_progress: [],
completed: [],
blocked: []
};
const sortedTasks = [...localTasks].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
sortedTasks.forEach(task => {
if (data[task.status]) {
data[task.status].push(task);
}
});
return data;
}, [localTasks]);
const handleDragEnd = (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!destination) return;
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const sourceCol = source.droppableId;
const destCol = destination.droppableId;
const sourceTasks = [...columnsData[sourceCol]];
const destTasks = sourceCol === destCol ? sourceTasks : [...columnsData[destCol]];
const [movedTask] = sourceTasks.splice(source.index, 1);
movedTask.status = destCol; // Optimistically update status
destTasks.splice(destination.index, 0, movedTask);
const newColumnsData = {
...columnsData,
[sourceCol]: sourceTasks,
[destCol]: destTasks,
};
// Flatten and assign new sort orders
const allTasksUpdated: any[] = [];
COLUMNS.forEach(col => {
newColumnsData[col.id].forEach((t, index) => {
allTasksUpdated.push({
...t,
sort_order: index,
status: col.id
});
});
});
// Optimistically update local state immediately
setLocalTasks(allTasksUpdated);
// We only really need to send the reordered tasks for the affected columns
// but sending all or just the affected ones is fine. Let's send all tasks to ensure correct ordering.
onReorder(allTasksUpdated.map(t => ({
ulid: t.ulid,
status: t.status,
sort_order: t.sort_order
})));
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<div className="flex gap-6 overflow-x-auto pb-4 h-full min-h-0">
{COLUMNS.map(column => (
<div key={column.id} className="flex-1 min-w-[300px] flex flex-col h-full min-h-0 bg-gray-50/50 rounded-xl border border-gray-100">
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white/50 rounded-t-xl shrink-0">
<h3 className="font-semibold text-gray-700">{column.title}</h3>
<Badge variant="secondary" className="bg-gray-100 text-gray-600">
{columnsData[column.id]?.length || 0}
</Badge>
</div>
<Droppable droppableId={column.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex-1 p-3 space-y-3 overflow-y-auto min-h-0 transition-colors duration-200 ease-in-out ${snapshot.isDraggingOver ? 'bg-blue-50/50 ring-1 ring-blue-200/50' : ''}`}
>
{columnsData[column.id]?.map((task, index) => (
<Draggable key={task.ulid} draggableId={task.ulid} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={provided.draggableProps.style}
onClick={() => onTaskClick(task.ulid)}
className="outline-none"
>
<Card className={`cursor-pointer hover:border-blue-300 transition-all duration-200 ease-in-out shadow-sm hover:shadow ${snapshot.isDragging ? 'shadow-xl border-blue-400 rotate-2 scale-[1.02] opacity-90' : ''}`}>
<CardContent className="p-4">
<div className="font-medium text-gray-900 mb-2 leading-tight">
{task.name}
</div>
<div className="flex items-center justify-between text-sm text-gray-500 mb-3">
<div className="flex items-center gap-1.5" title="Total Cost">
<DollarSign className="w-3.5 h-3.5" />
<span>{formatCurrency(task.total_cost || 0)}</span>
</div>
<div className="flex items-center gap-1.5" title="Estimated Hours">
<Clock className="w-3.5 h-3.5" />
<span>{task.estimated_hours || 0}h</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex -space-x-2">
{task.users?.slice(0, 3).map((user: any) => (
<Avatar key={user.ulid} className="w-6 h-6 border-2 border-white">
<AvatarFallback className="text-[10px] bg-blue-100 text-blue-700">
{user.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
{task.users?.length > 3 && (
<Avatar className="w-6 h-6 border-2 border-white">
<AvatarFallback className="text-[10px] bg-gray-100 text-gray-600">
+{task.users.length - 3}
</AvatarFallback>
</Avatar>
)}
</div>
{task.task_materials?.length > 0 && (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 font-normal">
{task.task_materials.length} Materials
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
))}
</div>
</DragDropContext>
);
}