Files
Ajjj ccbd23d474
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: enhance module RBAC permissions, financial billing workflows, task evidence uploading, and role analytics
2026-08-13 18:46:26 +08:00

225 lines
14 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, AvatarImage } from '@/Components/ui/avatar';
import { Clock, Users, Lock, GripVertical, Camera } from 'lucide-react';
interface KanbanBoardProps {
tasks: any[];
onReorder: (reorderedTasks: any[]) => void;
onTaskClick: (taskUlid: string) => void;
readOnly?: boolean;
}
const COLUMNS = [
{ id: 'pending', title: 'Pending' },
{ id: 'in_progress', title: 'In Progress' },
{ id: 'completed', title: 'Completed' },
{ id: 'blocked', title: 'Blocked' },
{ id: 'closed', title: 'Closed' }
];
export default function KanbanBoard({ tasks, onReorder, onTaskClick, readOnly = false }: 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: [],
closed: []
};
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} isDragDisabled={readOnly || task.status === 'closed'}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
className="outline-none"
>
<Card className={`transition-all duration-200 ease-in-out shadow-sm hover:shadow ${
task.status === 'in_progress' ? 'border-l-4 border-l-blue-500 hover:border-blue-300' :
task.status === 'completed' ? 'border-l-4 border-l-emerald-500 hover:border-emerald-300' :
task.status === 'blocked' ? 'border-l-4 border-l-rose-500 hover:border-rose-300' :
task.status === 'closed' ? 'border-l-4 border-l-orange-500 hover:border-orange-300' :
'border-l-4 border-l-slate-400 hover:border-slate-300'
} ${snapshot.isDragging ? 'shadow-xl border-blue-400 rotate-2 scale-[1.02] opacity-90' : ''}`}>
<CardContent className="p-3.5">
<div className="font-medium text-gray-900 mb-2 leading-tight flex items-start justify-between gap-2">
<div className="flex items-center gap-2 overflow-hidden cursor-pointer" onClick={() => onTaskClick(task.ulid)}>
{!readOnly && task.status !== 'closed' && (
<div {...provided.dragHandleProps} className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-slate-100 text-slate-400 hover:text-slate-600 shrink-0" title="Drag to move column">
<GripVertical className="w-4 h-4" />
</div>
)}
<span className="hover:text-blue-600 font-semibold truncate">{task.name}</span>
</div>
{task.status === 'closed' && <Lock className="w-3.5 h-3.5 text-orange-500 shrink-0 mt-0.5" />}
</div>
{task.description && (
<p className="text-xs text-gray-500 line-clamp-2 mb-3 leading-normal">
{task.description}
</p>
)}
<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">
<span>{formatCurrency(task.total_cost || 0)}</span>
</div>
<div className="flex items-center gap-2">
{task.evidence_urls && task.evidence_urls.length > 0 && (
<div className="flex items-center gap-1 text-[10px] text-indigo-600 font-semibold bg-indigo-50 px-1.5 py-0.5 rounded border border-indigo-100/80" title={`${task.evidence_urls.length} evidence photo(s)`}>
<Camera className="w-3 h-3 text-indigo-500" />
<span>{task.evidence_urls.length}</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>
<p className="pt-2 border-t border-gray-100 text-[10px] text-gray-400">
Select the task to view details and move its status.
</p>
<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">
{user.profile_picture && (
<AvatarImage src={user.profile_picture} alt={user.name} className="object-cover" />
)}
<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>
<div className="flex items-center gap-1.5 max-w-[60%] overflow-hidden">
{task.milestone && (
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200 text-[10px] h-5 px-1.5 font-normal truncate" title={task.milestone.name}>
{task.milestone.name}
</Badge>
)}
{task.task_materials?.length > 0 && (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 font-normal shrink-0">
{task.task_materials.length} Materials
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
))}
</div>
</DragDropContext>
);
}