184 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|