Run SQL migrations
This commit is contained in:
parent
746a8dbea0
commit
85b88d9ee0
1070
package-lock.json
generated
1070
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -45,10 +45,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"docx": "^8.2.3",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.1",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
39
src/App.tsx
39
src/App.tsx
@ -8,6 +8,8 @@ import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||
import Index from "./pages/Index";
|
||||
import Auth from "./pages/Auth";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Header from "./components/Header";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@ -33,26 +35,23 @@ const App = () => (
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
{/* Protected routes example (Dashboard will be implemented later) */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen pt-20 px-4">
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
<p>This is a protected route. Dashboard implementation coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Header />
|
||||
<div className="pt-16 sm:pt-20">
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
|
@ -1,76 +1,130 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Task } from '@/utils/mockData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileDown, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Download } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
task_name: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ExportOptionsProps {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
const ExportOptions = ({ tasks }: ExportOptionsProps) => {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
export const ExportOptions = ({ tasks }: ExportOptionsProps) => {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const exportToCSV = () => {
|
||||
const exportToCsv = () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
setExporting(true);
|
||||
|
||||
const headers = ['Task Name', 'Description', 'Priority', 'Due Date', 'Status'];
|
||||
|
||||
// Create CSV content
|
||||
const headers = ['Description', 'Department', 'Deadline', 'Priority', 'Status', 'Context'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...tasks.map(task => [
|
||||
`"${task.description.replace(/"/g, '""')}"`,
|
||||
`"${task.department.replace(/"/g, '""')}"`,
|
||||
task.deadline,
|
||||
task.priority,
|
||||
task.status,
|
||||
`"${task.context.replace(/"/g, '""')}"`
|
||||
].join(','))
|
||||
...tasks.map(task => {
|
||||
return [
|
||||
`"${task.task_name.replace(/"/g, '""')}"`,
|
||||
`"${task.description?.replace(/"/g, '""') || ''}"`,
|
||||
`"${task.priority}"`,
|
||||
`"${task.due_date ? new Date(task.due_date).toLocaleDateString() : ''}"`,
|
||||
`"${task.status}"`
|
||||
].join(',');
|
||||
})
|
||||
].join('\n');
|
||||
|
||||
// Create a Blob and download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `secupolicy_tasks_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.setAttribute('download', `tasks_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.success('CSV file exported successfully');
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
toast.error('Failed to export CSV file');
|
||||
console.error('Error exporting to CSV:', error);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportToPdf = () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Add title
|
||||
doc.setFontSize(16);
|
||||
doc.text('Policy Tasks Report', 14, 20);
|
||||
|
||||
// Add date
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Generated on: ${new Date().toLocaleDateString()}`, 14, 26);
|
||||
|
||||
// Prepare data for table
|
||||
const tableData = tasks.map(task => [
|
||||
task.task_name,
|
||||
task.description?.length > 60
|
||||
? task.description.substring(0, 60) + '...'
|
||||
: task.description || '',
|
||||
task.priority,
|
||||
task.due_date ? new Date(task.due_date).toLocaleDateString() : '',
|
||||
task.status
|
||||
]);
|
||||
|
||||
// Create table
|
||||
autoTable(doc, {
|
||||
head: [['Task Name', 'Description', 'Priority', 'Due Date', 'Status']],
|
||||
body: tableData,
|
||||
startY: 32,
|
||||
headStyles: { fillColor: [66, 66, 66] },
|
||||
margin: { top: 30 },
|
||||
styles: { overflow: 'linebreak' },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 50 },
|
||||
1: { cellWidth: 70 },
|
||||
},
|
||||
});
|
||||
|
||||
// Save the PDF
|
||||
doc.save(`tasks_export_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Error exporting to PDF:', error);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xs mx-auto">
|
||||
<Button
|
||||
onClick={exportToCSV}
|
||||
disabled={isExporting || tasks.length === 0}
|
||||
className="w-full rounded-full"
|
||||
variant="outline"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2 animate-pulse" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="w-4 h-4 mr-2" />
|
||||
Export to CSV
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="flex items-center">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={exportToCsv} disabled={exporting || tasks.length === 0}>
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportToPdf} disabled={exporting || tasks.length === 0}>
|
||||
Export as PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportOptions;
|
||||
|
@ -1,157 +1,156 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Task } from '@/utils/mockData';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, AlertCircle, Users, CornerDownRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
MoreVertical,
|
||||
Calendar,
|
||||
FileText,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
task_name: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onStatusChange: (id: string, status: 'Open' | 'In Progress' | 'Completed') => void;
|
||||
onPriorityChange: (id: string, priority: 'High' | 'Medium' | 'Low') => void;
|
||||
documentName: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
}
|
||||
|
||||
const TaskCard = ({ task, onStatusChange, onPriorityChange }: TaskCardProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
export const TaskCard = ({ task, documentName, onStatusChange }: TaskCardProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-200 dark:border-red-800/30';
|
||||
case 'Medium':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/30';
|
||||
case 'Low':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 border-green-200 dark:border-green-800/30';
|
||||
const getPriorityIcon = () => {
|
||||
switch (task.priority.toLowerCase()) {
|
||||
case 'high':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'medium':
|
||||
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
case 'low':
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300 border-gray-200 dark:border-gray-800/30';
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 border-green-200 dark:border-green-800/30';
|
||||
case 'In Progress':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 border-blue-200 dark:border-blue-800/30';
|
||||
case 'Open':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300 border-gray-200 dark:border-gray-800/30';
|
||||
const getPriorityBadge = () => {
|
||||
switch (task.priority.toLowerCase()) {
|
||||
case 'high':
|
||||
return <Badge variant="destructive" className="ml-2">{task.priority}</Badge>;
|
||||
case 'medium':
|
||||
return <Badge variant="default" className="ml-2">{task.priority}</Badge>;
|
||||
case 'low':
|
||||
return <Badge variant="secondary" className="ml-2">{task.priority}</Badge>;
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300 border-gray-200 dark:border-gray-800/30';
|
||||
return <Badge variant="outline" className="ml-2">{task.priority}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
const getStatusBadge = () => {
|
||||
switch (task.status.toLowerCase()) {
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500 ml-2">{task.status}</Badge>;
|
||||
case 'in progress':
|
||||
return <Badge className="bg-blue-500 ml-2">{task.status}</Badge>;
|
||||
case 'open':
|
||||
return <Badge variant="outline" className="ml-2">{task.status}</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline" className="ml-2">{task.status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDaysLeft = (dateString: string) => {
|
||||
const today = new Date();
|
||||
const deadline = new Date(dateString);
|
||||
const diffTime = deadline.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const daysLeft = calculateDaysLeft(task.deadline);
|
||||
const isUrgent = daysLeft <= 14 && daysLeft > 0;
|
||||
const isOverdue = daysLeft < 0;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"glass-panel p-5 transition-all duration-300 cursor-pointer hover-lift",
|
||||
task.status === 'Completed' && "opacity-70",
|
||||
isExpanded && "ring-1 ring-primary/20"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="font-medium text-base">{task.description}</h3>
|
||||
<Badge variant="outline" className={`ml-2 ${getPriorityColor(task.priority)}`}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground mb-3">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
{task.department}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
<span className={cn(
|
||||
isUrgent && "text-amber-600 dark:text-amber-400",
|
||||
isOverdue && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{formatDate(task.deadline)}
|
||||
{isUrgent && ` (${daysLeft} days left)`}
|
||||
{isOverdue && ` (Overdue by ${Math.abs(daysLeft)} days)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Badge variant="outline" className={getStatusColor(task.status)}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
defaultValue={task.priority}
|
||||
onValueChange={(value) => onPriorityChange(task.id, value as 'High' | 'Medium' | 'Low')}
|
||||
>
|
||||
<SelectTrigger className="w-[110px] h-8 text-xs">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Medium">Medium</SelectItem>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
defaultValue={task.status}
|
||||
onValueChange={(value) => onStatusChange(task.id, value as 'Open' | 'In Progress' | 'Completed')}
|
||||
>
|
||||
<SelectTrigger className="w-[110px] h-8 text-xs">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Open">Open</SelectItem>
|
||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border animate-fade-up">
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CornerDownRight className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Context from Policy:</p>
|
||||
<p>{task.context}</p>
|
||||
</div>
|
||||
<Card className={`overflow-hidden transition-shadow hover:shadow-md ${
|
||||
task.status === 'Completed' ? 'bg-muted/30' : ''
|
||||
}`}>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base line-clamp-2">
|
||||
{task.task_name}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center text-xs text-muted-foreground mt-1">
|
||||
<div className="flex items-center mr-3">
|
||||
{getPriorityIcon()}
|
||||
<span className="ml-1">{task.priority}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
<span className="truncate max-w-[120px]">{documentName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{task.status !== 'Completed' && (
|
||||
<DropdownMenuItem onClick={() => onStatusChange('Completed')}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Mark as completed
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{task.status === 'Completed' && (
|
||||
<DropdownMenuItem onClick={() => onStatusChange('Open')}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Mark as open
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{task.status !== 'In Progress' && task.status !== 'Completed' && (
|
||||
<DropdownMenuItem onClick={() => onStatusChange('In Progress')}>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Mark as in progress
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className={`text-sm ${expanded ? '' : 'line-clamp-2'}`}>
|
||||
{task.description}
|
||||
</div>
|
||||
{task.description && task.description.length > 100 && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto text-xs mt-1"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0 flex justify-between">
|
||||
<div className="flex items-center text-xs">
|
||||
{task.due_date && (
|
||||
<div className="flex items-center mr-2">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
<span>
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCard;
|
||||
|
@ -1,408 +1,418 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Task } from '@/utils/mockData';
|
||||
import TaskCard from './TaskCard';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Download, Filter, Search, Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Search, Filter, CheckCircle2, List, X } from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from '@/lib/utils';
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { TaskCard } from '@/components/TaskCard';
|
||||
import { ExportOptions } from '@/components/ExportOptions';
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
onTaskUpdate: (updatedTasks: Task[]) => void;
|
||||
interface Task {
|
||||
id: string;
|
||||
task_name: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
upload_id: string;
|
||||
}
|
||||
|
||||
const TaskList = ({ tasks, onTaskUpdate }: TaskListProps) => {
|
||||
const [filteredTasks, setFilteredTasks] = useState<Task[]>(tasks);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('deadline');
|
||||
const [filterPriority, setFilterPriority] = useState<string[]>([]);
|
||||
const [filterStatus, setFilterStatus] = useState<string[]>([]);
|
||||
const [filterDepartment, setFilterDepartment] = useState<string[]>([]);
|
||||
const [isFiltering, setIsFiltering] = useState(false);
|
||||
interface Upload {
|
||||
id: string;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
const handleStatusChange = (id: string, status: 'Open' | 'In Progress' | 'Completed') => {
|
||||
const updatedTasks = tasks.map(task =>
|
||||
task.id === id ? { ...task, status } : task
|
||||
);
|
||||
onTaskUpdate(updatedTasks);
|
||||
};
|
||||
|
||||
const handlePriorityChange = (id: string, priority: 'High' | 'Medium' | 'Low') => {
|
||||
const updatedTasks = tasks.map(task =>
|
||||
task.id === id ? { ...task, priority } : task
|
||||
);
|
||||
onTaskUpdate(updatedTasks);
|
||||
};
|
||||
|
||||
const uniqueDepartments = [...new Set(tasks.map(task => task.department))];
|
||||
|
||||
const sortTasks = (tasksToSort: Task[]) => {
|
||||
return [...tasksToSort].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'deadline':
|
||||
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
|
||||
case 'priority':
|
||||
const priorityOrder = { High: 0, Medium: 1, Low: 2 };
|
||||
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
case 'department':
|
||||
return a.department.localeCompare(b.department);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const filterTasks = () => {
|
||||
let result = tasks;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
result = result.filter(task =>
|
||||
task.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.context.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filterPriority.length > 0) {
|
||||
result = result.filter(task => filterPriority.includes(task.priority));
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (filterStatus.length > 0) {
|
||||
result = result.filter(task => filterStatus.includes(task.status));
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if (filterDepartment.length > 0) {
|
||||
result = result.filter(task => filterDepartment.includes(task.department));
|
||||
}
|
||||
|
||||
// Sort the filtered results
|
||||
return sortTasks(result);
|
||||
};
|
||||
const TaskList = () => {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [filteredTasks, setFilteredTasks] = useState<Task[]>([]);
|
||||
const [uploads, setUploads] = useState<Upload[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [documentFilter, setDocumentFilter] = useState<string>('');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'card'>('table');
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredTasks(filterTasks());
|
||||
setIsFiltering(
|
||||
searchQuery !== '' ||
|
||||
filterPriority.length > 0 ||
|
||||
filterStatus.length > 0 ||
|
||||
filterDepartment.length > 0
|
||||
);
|
||||
}, [tasks, searchQuery, sortBy, filterPriority, filterStatus, filterDepartment]);
|
||||
if (user) {
|
||||
fetchTasks();
|
||||
fetchUploads();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setFilterPriority([]);
|
||||
setFilterStatus([]);
|
||||
setFilterDepartment([]);
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [tasks, searchTerm, priorityFilter, statusFilter, documentFilter]);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('user_id', user?.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setTasks(data || []);
|
||||
setFilteredTasks(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load tasks',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completedTasksCount = tasks.filter(task => task.status === 'Completed').length;
|
||||
const taskCompletion = Math.round((completedTasksCount / tasks.length) * 100) || 0;
|
||||
|
||||
const fetchUploads = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('uploads')
|
||||
.select('id, file_name')
|
||||
.eq('user_id', user?.id);
|
||||
|
||||
if (error) throw error;
|
||||
setUploads(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching uploads:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
let result = [...tasks];
|
||||
|
||||
// Apply search term filter
|
||||
if (searchTerm) {
|
||||
result = result.filter(
|
||||
task =>
|
||||
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
if (priorityFilter) {
|
||||
result = result.filter(task => task.priority === priorityFilter);
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter) {
|
||||
result = result.filter(task => task.status === statusFilter);
|
||||
}
|
||||
|
||||
// Apply document filter
|
||||
if (documentFilter) {
|
||||
result = result.filter(task => task.upload_id === documentFilter);
|
||||
}
|
||||
|
||||
setFilteredTasks(result);
|
||||
};
|
||||
|
||||
const updateTaskStatus = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.update({ status: newStatus })
|
||||
.eq('id', taskId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === taskId ? { ...task, status: newStatus } : task
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: 'Task updated',
|
||||
description: `Task status changed to ${newStatus}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
toast({
|
||||
title: 'Update failed',
|
||||
description: 'Failed to update task status',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getDocumentName = (uploadId: string) => {
|
||||
const upload = uploads.find(u => u.id === uploadId);
|
||||
return upload ? upload.file_name : 'Unknown Document';
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
switch (priority.toLowerCase()) {
|
||||
case 'high':
|
||||
return <Badge variant="destructive">{priority}</Badge>;
|
||||
case 'medium':
|
||||
return <Badge variant="default">{priority}</Badge>;
|
||||
case 'low':
|
||||
return <Badge variant="secondary">{priority}</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{priority}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500">{status}</Badge>;
|
||||
case 'in progress':
|
||||
return <Badge className="bg-blue-500">{status}</Badge>;
|
||||
case 'open':
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setPriorityFilter('');
|
||||
setStatusFilter('');
|
||||
setDocumentFilter('');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="h-8 w-8 mx-auto animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Loading tasks...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<div className="flex flex-col mb-8">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between mb-4 gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Tasks</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{tasks.length} tasks extracted, {completedTasksCount} completed ({taskCompletion}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button variant="outline" className="rounded-full" onClick={clearFilters}
|
||||
disabled={!isFiltering}>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
View All Tasks
|
||||
</Button>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[180px] rounded-full">
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deadline">Sort by Deadline</SelectItem>
|
||||
<SelectItem value="priority">Sort by Priority</SelectItem>
|
||||
<SelectItem value="department">Sort by Department</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-2">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 rounded-full"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="rounded-full">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Priority
|
||||
{filterPriority.length > 0 && (
|
||||
<Badge className="ml-2 bg-primary/10 text-primary border-none">
|
||||
{filterPriority.length}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tasks</CardTitle>
|
||||
<CardDescription>
|
||||
Actionable items extracted from your policy documents
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search tasks..."
|
||||
className="pl-8"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
|
||||
>
|
||||
{viewMode === 'table' ?
|
||||
<div className="h-4 w-4 grid grid-cols-2 gap-0.5">
|
||||
<div className="bg-foreground rounded-sm"></div>
|
||||
<div className="bg-foreground rounded-sm"></div>
|
||||
<div className="bg-foreground rounded-sm"></div>
|
||||
<div className="bg-foreground rounded-sm"></div>
|
||||
</div> :
|
||||
<div className="h-4 w-4 flex flex-col justify-between">
|
||||
<div className="h-[2px] w-full bg-foreground"></div>
|
||||
<div className="h-[2px] w-full bg-foreground"></div>
|
||||
<div className="h-[2px] w-full bg-foreground"></div>
|
||||
</div>
|
||||
}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={filterPriority.includes('High')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterPriority([...filterPriority, 'High']);
|
||||
} else {
|
||||
setFilterPriority(filterPriority.filter(p => p !== 'High'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
High
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={filterPriority.includes('Medium')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterPriority([...filterPriority, 'Medium']);
|
||||
} else {
|
||||
setFilterPriority(filterPriority.filter(p => p !== 'Medium'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Medium
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={filterPriority.includes('Low')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterPriority([...filterPriority, 'Low']);
|
||||
} else {
|
||||
setFilterPriority(filterPriority.filter(p => p !== 'Low'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Low
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ExportOptions tasks={filteredTasks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="rounded-full">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Status
|
||||
{filterStatus.length > 0 && (
|
||||
<Badge className="ml-2 bg-primary/10 text-primary border-none">
|
||||
{filterStatus.length}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Priorities</SelectItem>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Medium">Medium</SelectItem>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Statuses</SelectItem>
|
||||
<SelectItem value="Open">Open</SelectItem>
|
||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={documentFilter} onValueChange={setDocumentFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Document" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Documents</SelectItem>
|
||||
{uploads.map((upload) => (
|
||||
<SelectItem key={upload.id} value={upload.id}>
|
||||
{upload.file_name.length > 25
|
||||
? `${upload.file_name.substring(0, 25)}...`
|
||||
: upload.file_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(searchTerm || priorityFilter || statusFilter || documentFilter) && (
|
||||
<Button variant="ghost" onClick={clearFilters} className="h-10">
|
||||
<X className="mr-2 h-3 w-3" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={filterStatus.includes('Open')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterStatus([...filterStatus, 'Open']);
|
||||
} else {
|
||||
setFilterStatus(filterStatus.filter(s => s !== 'Open'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={filterStatus.includes('In Progress')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterStatus([...filterStatus, 'In Progress']);
|
||||
} else {
|
||||
setFilterStatus(filterStatus.filter(s => s !== 'In Progress'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
In Progress
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={filterStatus.includes('Completed')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterStatus([...filterStatus, 'Completed']);
|
||||
} else {
|
||||
setFilterStatus(filterStatus.filter(s => s !== 'Completed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Completed
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="rounded-full">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Department
|
||||
{filterDepartment.length > 0 && (
|
||||
<Badge className="ml-2 bg-primary/10 text-primary border-none">
|
||||
{filterDepartment.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{uniqueDepartments.map(dept => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={dept}
|
||||
checked={filterDepartment.includes(dept)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFilterDepartment([...filterDepartment, dept]);
|
||||
} else {
|
||||
setFilterDepartment(filterDepartment.filter(d => d !== dept));
|
||||
}
|
||||
}}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="mb-4">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Filter className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">No tasks found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{tasks.length > 0
|
||||
? 'Try changing your filters or search term'
|
||||
: 'Upload a policy document to extract tasks'}
|
||||
</p>
|
||||
{tasks.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
>
|
||||
{dept}
|
||||
</DropdownMenuCheckboxItem>
|
||||
Upload Document
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Document</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="font-medium">{task.task_name}</TableCell>
|
||||
<TableCell>{getPriorityBadge(task.priority)}</TableCell>
|
||||
<TableCell>{getStatusBadge(task.status)}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{getDocumentName(task.upload_id)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{task.status !== 'Completed' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateTaskStatus(task.id, 'Completed')}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateTaskStatus(task.id, 'Open')}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
documentName={getDocumentName(task.upload_id)}
|
||||
onStatusChange={(newStatus) => updateTaskStatus(task.id, newStatus)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFiltering && (
|
||||
<div className="flex flex-wrap gap-2 my-3">
|
||||
{filterPriority.map(priority => (
|
||||
<Badge
|
||||
key={`priority-${priority}`}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 cursor-pointer hover:bg-background",
|
||||
priority === 'High' && "bg-red-100/50 dark:bg-red-900/20",
|
||||
priority === 'Medium' && "bg-yellow-100/50 dark:bg-yellow-900/20",
|
||||
priority === 'Low' && "bg-green-100/50 dark:bg-green-900/20"
|
||||
)}
|
||||
onClick={() => {
|
||||
setFilterPriority(filterPriority.filter(p => p !== priority));
|
||||
}}
|
||||
>
|
||||
{priority} <X className="ml-1 w-3 h-3" />
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{filterStatus.map(status => (
|
||||
<Badge
|
||||
key={`status-${status}`}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 cursor-pointer hover:bg-background",
|
||||
status === 'Completed' && "bg-green-100/50 dark:bg-green-900/20",
|
||||
status === 'In Progress' && "bg-blue-100/50 dark:bg-blue-900/20"
|
||||
)}
|
||||
onClick={() => {
|
||||
setFilterStatus(filterStatus.filter(s => s !== status));
|
||||
}}
|
||||
>
|
||||
{status} <X className="ml-1 w-3 h-3" />
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{filterDepartment.map(dept => (
|
||||
<Badge
|
||||
key={`dept-${dept}`}
|
||||
variant="outline"
|
||||
className="rounded-full px-3 py-1 cursor-pointer hover:bg-background"
|
||||
onClick={() => {
|
||||
setFilterDepartment(filterDepartment.filter(d => d !== dept));
|
||||
}}
|
||||
>
|
||||
{dept} <X className="ml-1 w-3 h-3" />
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full px-3 py-1 cursor-pointer hover:bg-background"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
Search: {searchQuery} <X className="ml-1 w-3 h-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 rounded-full"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="p-12 text-center glass-panel">
|
||||
<p className="text-lg font-medium">No tasks match your filters</p>
|
||||
<p className="text-muted-foreground mt-2">Try adjusting your search or filters</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 rounded-full"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onPriorityChange={handlePriorityChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {filteredTasks.length} of {tasks.length} tasks
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,168 +1,206 @@
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Upload, FileType, FileUp, AlertCircle, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Upload, FileIcon, Loader2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
interface UploadSectionProps {
|
||||
onUploadComplete: (fileName: string) => void;
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
const UploadSection = ({ onUploadComplete }: UploadSectionProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const allowedFileTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
];
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (isValidFile(droppedFile)) {
|
||||
setFile(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile && isValidFile(selectedFile)) {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const selectedFile = e.target.files[0];
|
||||
|
||||
if (!allowedFileTypes.includes(selectedFile.type)) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please upload a PDF or DOCX file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const isValidFile = (file: File) => {
|
||||
const validTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Please upload a PDF or Word document");
|
||||
return false;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||
toast.error("File size exceeds 10MB limit");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
if (!file || !user) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
// Simulate file upload with a delay
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
toast.success(`${file.name} uploaded successfully`);
|
||||
onUploadComplete(file.name);
|
||||
} catch (error) {
|
||||
toast.error("Upload failed. Please try again.");
|
||||
console.error("Upload error:", error);
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// First, create an entry in the uploads table
|
||||
const fileName = file.name;
|
||||
const fileType = file.type;
|
||||
const filePath = `${user.id}/${Date.now()}_${fileName}`;
|
||||
|
||||
const { data: uploadData, error: uploadError } = await supabase
|
||||
.from('uploads')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
file_name: fileName,
|
||||
file_path: filePath,
|
||||
file_type: fileType,
|
||||
status: 'Processing'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// Simulate progress for a better UX
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress((prev) => {
|
||||
const increment = Math.random() * 10;
|
||||
const newProgress = Math.min(prev + increment, 90);
|
||||
return newProgress;
|
||||
});
|
||||
}, 300);
|
||||
|
||||
// Upload the file to storage
|
||||
const { error: storageError } = await supabase
|
||||
.storage
|
||||
.from('documents')
|
||||
.upload(filePath, file);
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (storageError) throw storageError;
|
||||
|
||||
// Call the edge function to process the document
|
||||
const processingResponse = await supabase.functions.invoke('process-document', {
|
||||
body: {
|
||||
uploadId: uploadData.id,
|
||||
userId: user.id,
|
||||
filePath: filePath,
|
||||
fileType: fileType
|
||||
}
|
||||
});
|
||||
|
||||
if (processingResponse.error) throw new Error(processingResponse.error.message);
|
||||
|
||||
setUploadProgress(100);
|
||||
|
||||
toast({
|
||||
title: 'Upload successful',
|
||||
description: 'Your document is now being processed',
|
||||
});
|
||||
|
||||
// Clear file and reset progress after 1 second
|
||||
setTimeout(() => {
|
||||
setFile(null);
|
||||
setUploadProgress(0);
|
||||
onUploadComplete();
|
||||
}, 1000);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error);
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error.message || 'An error occurred while uploading the document',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-2">Upload Your Policy Document</h2>
|
||||
<p className="text-muted-foreground">Upload a PDF or Word document to extract actionable tasks</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`
|
||||
glass-panel p-8
|
||||
border-2 border-dashed
|
||||
flex flex-col items-center justify-center
|
||||
transition-all duration-300
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-border'}
|
||||
${file ? 'border-green-500/30 bg-green-50/30 dark:bg-green-900/10' : ''}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx"
|
||||
/>
|
||||
|
||||
{!file ? (
|
||||
<>
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Upload className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">Drag and drop your file here</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md text-center">
|
||||
Support for PDF and Word documents up to 10MB
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Browse Files
|
||||
<div className="space-y-4">
|
||||
{!file ? (
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<label className="flex flex-col items-center justify-center cursor-pointer space-y-3">
|
||||
<Upload className="h-10 w-10 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">Drag and drop or click to upload</span>
|
||||
<span className="text-sm text-muted-foreground">PDF or DOCX files only</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button variant="outline" type="button">
|
||||
Select file
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center">
|
||||
<FileType className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2 rounded">
|
||||
<FileIcon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-medium mb-1 flex items-center justify-center">
|
||||
{file.name}
|
||||
<Check className="w-4 h-4 text-green-500 ml-2" />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
size="sm"
|
||||
className="rounded-full min-w-24"
|
||||
>
|
||||
{isUploading ? 'Processing...' : 'Analyze Document'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={clearFile}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">{uploadProgress.toFixed(0)}% complete</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Document
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm text-muted-foreground flex items-center justify-center">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
Your documents are processed securely and never stored permanently
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>After uploading, your document will be automatically processed to extract actionable tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,26 +1,37 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
value?: number
|
||||
max?: number
|
||||
indicatorClassName?: string
|
||||
}
|
||||
>(({ className, value, max = 100, indicatorClassName, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
indicatorClassName
|
||||
)}
|
||||
style={{
|
||||
transform: `translateX(-${100 - ((value || 0) / max) * 100}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
|
@ -1,10 +1,11 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
React.HTMLTableProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
@ -18,7 +19,7 @@ Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
React.HTMLTableSectionProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
@ -26,7 +27,7 @@ TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
React.HTMLTableSectionProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
@ -38,14 +39,11 @@ TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
React.HTMLTableSectionProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
className={cn("bg-primary font-medium text-primary-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -53,7 +51,7 @@ TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
React.HTMLTableRowProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
|
@ -36,6 +36,86 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
tasks: {
|
||||
Row: {
|
||||
created_at: string
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
priority: string | null
|
||||
status: string
|
||||
task_name: string
|
||||
updated_at: string
|
||||
upload_id: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
priority?: string | null
|
||||
status?: string
|
||||
task_name: string
|
||||
updated_at?: string
|
||||
upload_id: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
priority?: string | null
|
||||
status?: string
|
||||
task_name?: string
|
||||
updated_at?: string
|
||||
upload_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "tasks_upload_id_fkey"
|
||||
columns: ["upload_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "uploads"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
uploads: {
|
||||
Row: {
|
||||
created_at: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_type: string
|
||||
id: string
|
||||
status: string
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_type: string
|
||||
id?: string
|
||||
status?: string
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
file_name?: string
|
||||
file_path?: string
|
||||
file_type?: string
|
||||
id?: string
|
||||
status?: string
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
|
@ -1,167 +1,196 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Upload, FilePlus, FileText, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import UploadSection from '@/components/UploadSection';
|
||||
import TaskList from '@/components/TaskList';
|
||||
import ExportOptions from '@/components/ExportOptions';
|
||||
import { Task, generateTasks } from '@/utils/mockData';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [uploadCompleted, setUploadCompleted] = useState(false);
|
||||
const [documentName, setDocumentName] = useState('');
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'tasks'>('upload');
|
||||
const [uploadCount, setUploadCount] = useState(0);
|
||||
const [taskCount, setTaskCount] = useState(0);
|
||||
const [recentUploads, setRecentUploads] = useState<any[]>([]);
|
||||
const [processingUploads, setProcessingUploads] = useState<number>(0);
|
||||
|
||||
const handleUploadComplete = (fileName: string) => {
|
||||
// Simulate task extraction with a delay
|
||||
setTimeout(() => {
|
||||
const extractedTasks = generateTasks(fileName);
|
||||
setTasks(extractedTasks);
|
||||
setDocumentName(fileName);
|
||||
setUploadCompleted(true);
|
||||
|
||||
toast.success(`${extractedTasks.length} tasks extracted successfully`, {
|
||||
description: 'Tasks are now ready for review and prioritization'
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchDashboardData();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
// Get upload count
|
||||
const { count: uploadCountData, error: uploadCountError } = await supabase
|
||||
.from('uploads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user?.id);
|
||||
|
||||
if (uploadCountError) throw uploadCountError;
|
||||
setUploadCount(uploadCountData || 0);
|
||||
|
||||
// Get task count
|
||||
const { count: taskCountData, error: taskCountError } = await supabase
|
||||
.from('tasks')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user?.id);
|
||||
|
||||
if (taskCountError) throw taskCountError;
|
||||
setTaskCount(taskCountData || 0);
|
||||
|
||||
// Get recent uploads
|
||||
const { data: recentUploadsData, error: recentUploadsError } = await supabase
|
||||
.from('uploads')
|
||||
.select('*')
|
||||
.eq('user_id', user?.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
if (recentUploadsError) throw recentUploadsError;
|
||||
setRecentUploads(recentUploadsData || []);
|
||||
|
||||
// Get processing uploads count
|
||||
const { count: processingCount, error: processingError } = await supabase
|
||||
.from('uploads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user?.id)
|
||||
.eq('status', 'Processing');
|
||||
|
||||
if (processingError) throw processingError;
|
||||
setProcessingUploads(processingCount || 0);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load dashboard data',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleTaskUpdate = (updatedTasks: Task[]) => {
|
||||
setTasks(updatedTasks);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<main className="flex-grow pt-20">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Tabs defaultValue="upload" className="w-full">
|
||||
<div className="flex justify-center mb-8">
|
||||
<TabsList className="rounded-full h-12">
|
||||
<TabsTrigger value="upload" className="rounded-full px-6">
|
||||
Upload Document
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tasks"
|
||||
className="rounded-full px-6"
|
||||
disabled={!uploadCompleted}
|
||||
onClick={() => {
|
||||
if (!uploadCompleted) {
|
||||
toast.error("Please upload a document first");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Review Tasks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="export"
|
||||
className="rounded-full px-6"
|
||||
disabled={!uploadCompleted}
|
||||
onClick={() => {
|
||||
if (!uploadCompleted) {
|
||||
toast.error("Please upload a document first");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Export
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Documents</CardTitle>
|
||||
<CardDescription>Total uploaded documents</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{uploadCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Tasks</CardTitle>
|
||||
<CardDescription>Extracted actionable items</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{taskCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Processing</CardTitle>
|
||||
<CardDescription>Documents being analyzed</CardDescription>
|
||||
</div>
|
||||
|
||||
<TabsContent value="upload" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<UploadSection onUploadComplete={handleUploadComplete} />
|
||||
|
||||
{uploadCompleted && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className="glass-panel px-4 py-2 text-sm flex items-center">
|
||||
<AlertCircle className="w-4 h-4 mr-2 text-green-500" />
|
||||
Document processed successfully! Continue to Review Tasks.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
{uploadCompleted ? (
|
||||
<>
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Extracted tasks from
|
||||
{processingUploads > 0 && (
|
||||
<div className="rounded-full bg-amber-500/20 p-1">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{processingUploads}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
<Button
|
||||
variant={activeTab === 'upload' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Document
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={activeTab === 'tasks' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
View Tasks
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'upload' ? (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Policy Document</CardTitle>
|
||||
<CardDescription>
|
||||
Upload your IT security or compliance policy documents to extract actionable tasks
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UploadSection onUploadComplete={fetchDashboardData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{recentUploads.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Uploads</CardTitle>
|
||||
<CardDescription>
|
||||
Your most recent document uploads
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentUploads.map((upload) => (
|
||||
<div key={upload.id} className="flex items-center justify-between border-b pb-4 last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<FilePlus className="h-6 w-6 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{upload.file_name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(upload.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
upload.status === 'Completed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}`}>
|
||||
{upload.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">{documentName}</h3>
|
||||
</div>
|
||||
<TaskList tasks={tasks} onTaskUpdate={handleTaskUpdate} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center p-12">
|
||||
<p className="text-muted-foreground">Please upload a document first</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
{uploadCompleted ? (
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<div className="mb-8">
|
||||
<h3 className="text-2xl font-semibold mb-2">Export Task List</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Download your tasks to share with your team or import into your project management tool
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-8 mb-8">
|
||||
<div className="text-left mb-6">
|
||||
<h4 className="font-medium mb-1">Export Summary</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>Document: {documentName}</li>
|
||||
<li>Total Tasks: {tasks.length}</li>
|
||||
<li>Open Tasks: {tasks.filter(t => t.status === 'Open').length}</li>
|
||||
<li>In Progress Tasks: {tasks.filter(t => t.status === 'In Progress').length}</li>
|
||||
<li>Completed Tasks: {tasks.filter(t => t.status === 'Completed').length}</li>
|
||||
<li>High Priority Tasks: {tasks.filter(t => t.priority === 'High').length}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ExportOptions tasks={tasks} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Need to make changes? Go back to the <TabsTrigger value="tasks" className="text-primary underline bg-transparent p-0 h-auto">Review Tasks</TabsTrigger> tab.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-12">
|
||||
<p className="text-muted-foreground">Please upload a document first</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="bg-muted/30 py-6">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
© 2024 SecuPolicy. All rights reserved.
|
||||
</div>
|
||||
<div className="flex space-x-6">
|
||||
<a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
Terms of Service
|
||||
</a>
|
||||
<a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
Contact
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
) : (
|
||||
<TaskList />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
2
supabase/functions/process-document/config.toml
Normal file
2
supabase/functions/process-document/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
ffwcnetryatmpcnjkegg
|
155
supabase/functions/process-document/index.ts
Normal file
155
supabase/functions/process-document/index.ts
Normal file
@ -0,0 +1,155 @@
|
||||
|
||||
import "https://deno.land/x/xhr@0.1.0/mod.ts";
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.38.1";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY');
|
||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || "https://ffwcnetryatmpcnjkegg.supabase.co";
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || "";
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { uploadId, userId, filePath, fileType } = await req.json();
|
||||
|
||||
// Create Supabase client with service role key to bypass RLS
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch the file content from Supabase Storage
|
||||
const { data: fileData, error: fileError } = await supabase
|
||||
.storage
|
||||
.from('documents')
|
||||
.download(filePath);
|
||||
|
||||
if (fileError) {
|
||||
console.error('Error downloading file:', fileError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Error downloading file' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Convert file content to text
|
||||
const text = await fileData.text();
|
||||
|
||||
// Extract tasks using OpenAI
|
||||
const tasks = await extractTasksWithOpenAI(text, uploadId, userId);
|
||||
|
||||
// Save extracted tasks to the database
|
||||
for (const task of tasks) {
|
||||
const { error: taskError } = await supabase
|
||||
.from('tasks')
|
||||
.insert(task);
|
||||
|
||||
if (taskError) {
|
||||
console.error('Error inserting task:', taskError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update upload status to 'Completed'
|
||||
const { error: updateError } = await supabase
|
||||
.from('uploads')
|
||||
.update({ status: 'Completed' })
|
||||
.eq('id', uploadId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating upload status:', updateError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Error updating upload status' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, tasksExtracted: tasks.length }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing document:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function extractTasksWithOpenAI(text: string, uploadId: string, userId: string) {
|
||||
try {
|
||||
// Call OpenAI API to extract tasks
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `
|
||||
You are a specialized task extractor for IT security and compliance policy documents.
|
||||
Your job is to identify specific actionable tasks from policy documents.
|
||||
For each task, extract the following information:
|
||||
1. Task name (short, clear description of what needs to be done)
|
||||
2. Description (more detailed explanation)
|
||||
3. Priority (High, Medium, Low)
|
||||
4. Due date (if mentioned)
|
||||
|
||||
Format your response as a valid JSON array of task objects with these properties:
|
||||
[
|
||||
{
|
||||
"task_name": "string",
|
||||
"description": "string",
|
||||
"priority": "string",
|
||||
"due_date": "ISO date string or null"
|
||||
}
|
||||
]
|
||||
|
||||
If no due date is mentioned for a task, return null for that field.
|
||||
Only return the JSON array, nothing else.
|
||||
`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: text
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const extractedTasksJson = data.choices[0].message.content;
|
||||
|
||||
// Parse the extracted tasks
|
||||
const extractedTasks = JSON.parse(extractedTasksJson);
|
||||
|
||||
// Format tasks for database insertion
|
||||
return extractedTasks.map((task: any) => ({
|
||||
upload_id: uploadId,
|
||||
user_id: userId,
|
||||
task_name: task.task_name,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
due_date: task.due_date,
|
||||
status: 'Open'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error extracting tasks with OpenAI:', error);
|
||||
throw new Error('Failed to extract tasks from document');
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user