Files
Krow-workspace/frontend-web/src/pages/TaskBoard.jsx
2025-11-18 21:32:16 -05:00

466 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
import { Link2, Plus, Users } from "lucide-react";
import TaskCard from "@/components/tasks/TaskCard";
import TaskColumn from "@/components/tasks/TaskColumn";
import TaskDetailModal from "@/components/tasks/TaskDetailModal";
import { useToast } from "@/components/ui/use-toast";
export default function TaskBoard() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [createDialog, setCreateDialog] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
const [selectedStatus, setSelectedStatus] = useState("pending");
const [newTask, setNewTask] = useState({
task_name: "",
description: "",
priority: "normal",
due_date: "",
progress: 0,
assigned_members: []
});
const [selectedMembers, setSelectedMembers] = useState([]);
const { data: user } = useQuery({
queryKey: ['current-user-taskboard'],
queryFn: () => base44.auth.me(),
});
const { data: teams = [] } = useQuery({
queryKey: ['teams'],
queryFn: () => base44.entities.Team.list(),
initialData: [],
});
const { data: teamMembers = [] } = useQuery({
queryKey: ['team-members'],
queryFn: () => base44.entities.TeamMember.list(),
initialData: [],
});
const { data: tasks = [] } = useQuery({
queryKey: ['tasks'],
queryFn: () => base44.entities.Task.list(),
initialData: [],
});
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
const regularMembers = currentTeamMembers.filter(m => m.role === 'member');
// Get unique departments from team members
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
const tasksByStatus = useMemo(() => ({
pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
}), [teamTasks]);
const overallProgress = useMemo(() => {
if (teamTasks.length === 0) return 0;
const totalProgress = teamTasks.reduce((sum, t) => sum + (t.progress || 0), 0);
return Math.round(totalProgress / teamTasks.length);
}, [teamTasks]);
const createTaskMutation = useMutation({
mutationFn: (taskData) => base44.entities.Task.create(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setCreateDialog(false);
setNewTask({
task_name: "",
description: "",
priority: "normal",
due_date: "",
progress: 0,
assigned_members: []
});
setSelectedMembers([]);
toast({
title: "✅ Task Created",
description: "New task added to the board",
});
},
});
const updateTaskMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const task = teamTasks.find(t => t.id === draggableId);
if (!task) return;
const newStatus = destination.droppableId;
updateTaskMutation.mutate({
id: task.id,
data: {
...task,
status: newStatus,
order_index: destination.index
}
});
};
const handleCreateTask = () => {
if (!newTask.task_name.trim()) {
toast({
title: "Task name required",
variant: "destructive",
});
return;
}
createTaskMutation.mutate({
...newTask,
team_id: userTeam?.id,
status: selectedStatus,
order_index: tasksByStatus[selectedStatus]?.length || 0,
assigned_members: selectedMembers.map(m => ({
member_id: m.id,
member_name: m.member_name,
avatar_url: m.avatar_url
})),
assigned_department: selectedMembers.length > 0 && selectedMembers[0].department ? selectedMembers[0].department : null
});
};
return (
<div className="p-6 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-600">Lead</span>
<div className="flex -space-x-2">
{leadMembers.slice(0, 3).map((member, idx) => (
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{leadMembers.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{leadMembers.length - 3}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-600">Team</span>
<div className="flex -space-x-2">
{regularMembers.slice(0, 3).map((member, idx) => (
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{regularMembers.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{regularMembers.length - 3}
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="border-slate-300">
<Link2 className="w-4 h-4 mr-2" />
Share
</Button>
<Button
onClick={() => {
setSelectedStatus("pending");
setCreateDialog(true);
}}
className="bg-[#0A39DF] hover:bg-blue-700"
>
<Plus className="w-4 h-4 mr-2" />
Create List
</Button>
</div>
</div>
{/* Overall Progress */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#0A39DF] to-blue-600 transition-all"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="text-sm font-bold text-slate-900 ml-4">{overallProgress}%</span>
</div>
</div>
</div>
{/* Kanban Board */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{['pending', 'in_progress', 'on_hold', 'completed'].map((status) => (
<TaskColumn
key={status}
status={status}
tasks={tasksByStatus[status]}
onAddTask={(status) => {
setSelectedStatus(status);
setCreateDialog(true);
}}
>
{tasksByStatus[status].map((task, index) => (
<Draggable key={task.id} draggableId={task.id} index={index}>
{(provided) => (
<TaskCard
task={task}
provided={provided}
onClick={() => setSelectedTask(task)}
/>
)}
</Draggable>
))}
</TaskColumn>
))}
</div>
</DragDropContext>
{teamTasks.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-300">
<div className="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
<Plus className="w-8 h-8 text-slate-400" />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-2">No tasks yet</h3>
<p className="text-slate-600 mb-5">Create your first task to get started</p>
<Button onClick={() => setCreateDialog(true)} className="bg-[#0A39DF]">
<Plus className="w-4 h-4 mr-2" />
Create Task
</Button>
</div>
)}
</div>
{/* Create Task Dialog */}
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Task Name *</Label>
<Input
value={newTask.task_name}
onChange={(e) => setNewTask({ ...newTask, task_name: e.target.value })}
placeholder="e.g., Website Design"
className="mt-1"
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
placeholder="Task details..."
rows={3}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Priority</Label>
<Select value={newTask.priority} onValueChange={(val) => setNewTask({ ...newTask, priority: val })}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Due Date</Label>
<Input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
className="mt-1"
/>
</div>
</div>
<div>
<Label>Initial Progress (%)</Label>
<Input
type="number"
min="0"
max="100"
value={newTask.progress}
onChange={(e) => setNewTask({ ...newTask, progress: parseInt(e.target.value) || 0 })}
className="mt-1"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label>Assign Team Members</Label>
{departments.length > 0 && (
<Select onValueChange={(dept) => {
const deptMembers = currentTeamMembers.filter(m => m.department === dept);
setSelectedMembers(deptMembers);
}}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Assign entire department" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => {
const count = currentTeamMembers.filter(m => m.department === dept).length;
return (
<SelectItem key={dept} value={dept}>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
{dept} ({count} members)
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
<div className="mt-2 space-y-2">
{currentTeamMembers.length === 0 ? (
<p className="text-sm text-slate-500">No team members available</p>
) : (
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{currentTeamMembers.map((member) => {
const isSelected = selectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
onClick={() => {
if (isSelected) {
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
} else {
setSelectedMembers([...selectedMembers, member]);
}
}}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all ${
isSelected ? 'bg-blue-50 border-2 border-[#0A39DF]' : 'hover:bg-slate-50 border-2 border-transparent'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="w-4 h-4 rounded text-[#0A39DF] focus:ring-[#0A39DF]"
/>
<Avatar className="w-8 h-8">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium text-slate-900">{member.member_name}</p>
<p className="text-xs text-slate-500">
{member.department ? `${member.department}` : ''}{member.role || 'Member'}
</p>
</div>
</div>
);
})}
</div>
)}
{selectedMembers.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-slate-50 rounded-lg">
{selectedMembers.map((member) => (
<Badge key={member.id} className="bg-[#0A39DF] text-white flex items-center gap-1">
{member.member_name}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
}}
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreateTask}
disabled={createTaskMutation.isPending}
className="bg-[#0A39DF]"
>
Create Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Task Detail Modal with Comments */}
<TaskDetailModal
task={selectedTask}
open={!!selectedTask}
onClose={() => setSelectedTask(null)}
/>
</div>
);
}