310 lines
17 KiB
TypeScript
310 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Calendar, List, Plus, CheckCircle2, Clock,
|
|
RefreshCw, User, MapPin, Filter, MoreHorizontal,
|
|
Search, AlertCircle, LayoutGrid, CheckSquare,
|
|
ClipboardList, ArrowRight
|
|
} from 'lucide-react';
|
|
import { tasksApi, Task } from '../lib/tasksApi';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useToast } from '../context/ToastContext';
|
|
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
|
import CompleteTaskModal from '../components/tasks/CompleteTaskModal';
|
|
import { Card } from '../components/ui/card';
|
|
import { cn } from '../lib/utils';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
|
|
export default function TasksPage() {
|
|
const { user } = useAuth();
|
|
const { addToast } = useToast();
|
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
const [completionTask, setCompletionTask] = useState<Task | null>(null);
|
|
const [filter, setFilter] = useState<'all' | 'mine'>('mine');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
useEffect(() => {
|
|
loadTasks();
|
|
}, [filter]);
|
|
|
|
const loadTasks = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await tasksApi.getAll({
|
|
assigneeId: filter === 'mine' ? user?.id : undefined
|
|
});
|
|
setTasks(data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
addToast('Failed to load tasks', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleTaskCompleted = () => {
|
|
loadTasks();
|
|
setCompletionTask(null);
|
|
addToast('Task finalized and logged', 'success');
|
|
};
|
|
|
|
// Filter tasks by search
|
|
const filteredTasks = tasks.filter(t =>
|
|
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
t.room?.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
const pendingTasks = filteredTasks.filter(t => t.status !== 'COMPLETED');
|
|
const completedTasks = filteredTasks.filter(t => t.status === 'COMPLETED');
|
|
|
|
return (
|
|
<div className="space-y-8 pb-12 overflow-hidden">
|
|
{/* Page Header */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
|
|
Operational Tasks
|
|
</h1>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-[10px] font-bold uppercase tracking-wider border border-[var(--color-primary)]/20">
|
|
{pendingTasks.length} Pending
|
|
</div>
|
|
<span className="text-[var(--color-text-tertiary)] text-sm">
|
|
Real-time workflow management
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="Filter by room or task..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 pr-4 py-2 bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] rounded-lg text-sm transition-all focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 w-64"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
|
|
>
|
|
<Plus size={18} />
|
|
New Task
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics Row */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<MetricCard label="Assigned to Me" value={tasks.filter(t => t.assigneeId === user?.id).length} icon={User} color="text-[var(--color-primary)]" />
|
|
<MetricCard label="Overdue" value={3} icon={AlertCircle} color="text-[var(--color-error)]" />
|
|
<MetricCard label="Due Today" value={tasks.filter(t => t.status !== 'COMPLETED').length} icon={Clock} color="text-[var(--color-warning)]" />
|
|
<MetricCard label="Completed 24h" value={completedTasks.length} icon={CheckCircle2} color="text-[var(--color-accent)]" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
|
{/* Left Column: Filters & View Switching */}
|
|
<div className="lg:col-span-3 space-y-6">
|
|
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)]">
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-[var(--color-text-tertiary)] mb-4 px-2">Focus</h4>
|
|
<div className="space-y-1">
|
|
<SidebarFilterButton active={filter === 'mine'} onClick={() => setFilter('mine')} label="My Work" icon={CheckSquare} count={tasks.filter(t => t.assigneeId === user?.id).length} />
|
|
<SidebarFilterButton active={filter === 'all'} onClick={() => setFilter('all')} label="Facility All" icon={LayoutGrid} count={tasks.length} />
|
|
</div>
|
|
|
|
<div className="h-px bg-slate-100 dark:bg-slate-800 my-4 mx-2" />
|
|
|
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-[var(--color-text-tertiary)] mb-4 px-2">Category</h4>
|
|
<div className="space-y-1">
|
|
<SidebarFilterButton active={false} onClick={() => { }} label="Irrigation" icon={Droplets} />
|
|
<SidebarFilterButton active={false} onClick={() => { }} label="IPM & Health" icon={Bug} />
|
|
<SidebarFilterButton active={false} onClick={() => { }} label="Compliance" icon={ClipboardList} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 bg-[var(--color-bg-tertiary)]/50 border-[var(--color-border-subtle)] border-dashed">
|
|
<p className="text-[10px] text-[var(--color-text-tertiary)] font-medium leading-relaxed">
|
|
<AlertCircle size={10} className="inline mr-1 mb-0.5" />
|
|
System Note:
|
|
</p>
|
|
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-1 leading-relaxed">
|
|
Urgent maintenance scheduled for Flower A irrigation pump. All related tasks prioritized.
|
|
</p>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Column: Task Board */}
|
|
<div className="lg:col-span-9 space-y-8">
|
|
{/* Active Tasks Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between px-2">
|
|
<h3 className="text-xs font-bold uppercase tracking-wide text-[var(--color-text-tertiary)]">Active Board</h3>
|
|
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)]">{pendingTasks.length} ITEMS</span>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<AnimatePresence mode="popLayout">
|
|
{pendingTasks.map((task, idx) => (
|
|
<motion.div
|
|
key={task.id}
|
|
layout
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
>
|
|
<TaskRow
|
|
task={task}
|
|
onComplete={() => setCompletionTask(task)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
{pendingTasks.length === 0 && (
|
|
<div className="py-12 text-center rounded-2xl border-2 border-dashed border-[var(--color-border-subtle)]">
|
|
<CheckCircle2 size={32} className="mx-auto text-[var(--color-primary)]/20 mb-3" />
|
|
<p className="text-sm font-medium text-[var(--color-text-tertiary)]">All Actions Complete</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recently Completed Section */}
|
|
{completedTasks.length > 0 && (
|
|
<div className="space-y-4 pt-4 border-t border-[var(--color-border-subtle)]">
|
|
<div className="flex items-center justify-between px-2">
|
|
<h3 className="text-xs font-bold uppercase tracking-wide text-[var(--color-text-tertiary)]">Archive / Verification</h3>
|
|
<button className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest hover:text-[var(--color-primary)]">View History</button>
|
|
</div>
|
|
<div className="opacity-60 saturate-50 hover:opacity-100 transition-all duration-500 space-y-2">
|
|
{completedTasks.slice(0, 3).map(task => (
|
|
<div key={task.id} className="flex items-center justify-between p-4 bg-slate-50/50 dark:bg-slate-900/30 rounded-xl border border-[var(--color-border-subtle)]/50">
|
|
<div className="flex items-center gap-4">
|
|
<CheckCircle2 size={16} className="text-[var(--color-primary)]" />
|
|
<div>
|
|
<p className="text-xs font-bold text-[var(--color-text-tertiary)] line-through tracking-tight">{task.title}</p>
|
|
<p className="text-[10px] text-[var(--color-text-tertiary)] uppercase font-medium">{task.room?.name} • Finalized by {task.assignee?.name}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<CreateTaskModal
|
|
isOpen={isCreateOpen}
|
|
onClose={() => setIsCreateOpen(false)}
|
|
onSuccess={() => loadTasks()}
|
|
/>
|
|
|
|
{completionTask && (
|
|
<CompleteTaskModal
|
|
task={completionTask}
|
|
onClose={() => setCompletionTask(null)}
|
|
onSuccess={handleTaskCompleted}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ label, value, icon: Icon, color }: any) {
|
|
return (
|
|
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] relative group overflow-hidden">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider">{label}</p>
|
|
<div className="text-2xl font-bold text-[var(--color-text-primary)]">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
<div className={cn("p-2 rounded-lg bg-slate-100 dark:bg-slate-800/50", color)}>
|
|
<Icon size={18} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SidebarFilterButton({ active, label, icon: Icon, count, onClick }: any) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all group",
|
|
active
|
|
? "bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-emerald-500/20"
|
|
: "text-[var(--color-text-tertiary)] hover:bg-slate-100 dark:hover:bg-slate-900 hover:text-slate-900 dark:hover:text-slate-100"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Icon size={16} className={active ? "text-[var(--color-primary)]" : "text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-primary)]"} />
|
|
<span>{label}</span>
|
|
</div>
|
|
{count !== undefined && (
|
|
<span className={cn(
|
|
"text-[10px] px-1.5 py-0.5 rounded-md",
|
|
active ? "bg-[var(--color-primary)] text-white" : "bg-slate-200 dark:bg-slate-800 text-[var(--color-text-tertiary)]"
|
|
)}>{count}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TaskRow({ task, onComplete }: any) {
|
|
return (
|
|
<div className="group flex items-center gap-4 p-5 bg-[var(--color-bg-elevated)] border border-[var(--color-border-subtle)] rounded-2xl hover:border-[var(--color-primary)]/50 transition-all">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{task.title}
|
|
</h4>
|
|
{task.priority === 'URGENT' && (
|
|
<span className="px-2 py-0.5 rounded text-[8px] font-bold uppercase tracking-wide bg-[var(--color-error)] text-white animate-pulse">
|
|
Priority: High
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6 text-xs text-[var(--color-text-tertiary)]">
|
|
<div className="flex items-center gap-1.5">
|
|
<MapPin size={12} className="text-[var(--color-primary)]/50" />
|
|
<span>{task.room?.name || 'GEN-01'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<User size={12} className="text-[var(--color-text-tertiary)]" />
|
|
<span>{task.assignee?.name || 'Unassigned'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Clock size={12} className="text-[var(--color-text-tertiary)]" />
|
|
<span>Due {new Date(task.dueDate).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button className="p-2 hidden group-hover:flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-slate-600 transition-all">
|
|
<MoreHorizontal size={18} />
|
|
</button>
|
|
<button
|
|
onClick={onComplete}
|
|
className="flex items-center gap-2 h-10 px-4 rounded-xl bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-text-inverse)] transition-all text-xs font-medium group/btn"
|
|
>
|
|
<CheckCircle2 size={16} className="text-[var(--color-text-tertiary)] group-hover/btn:text-white" />
|
|
<span className="opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">Finalize Action</span>
|
|
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Droplets({ size, className }: any) { return <ClipboardList size={size} className={className} />; }
|
|
function Bug({ size, className }: any) { return <AlertCircle size={size} className={className} />; }
|
|
|