pmtool / src /pages /deliverables.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
29.2 kB
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Edit, MoreHorizontal, Plus, Search, Trash, Calendar, Check, X, ChevronDown, LayoutGrid, Table2, BarChart3, Clock, CheckCircle2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { format, parseISO } from "date-fns";
import { toast } from "@/lib/custom-toast";
// UI Components
import Layout from "@/components/layout/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuCheckboxItem,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Dropdown, DropdownOption } from "@/components/ui/dropdown";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Types & API
import { Deliverable, ProjectApi, IssueApi } from "@/types";
import { deliverablesApi, projectService, issuesApi, userSessionService } from "@/lib/api";
const DeliverablesList = () => {
const [searchQuery, setSearchQuery] = useState("");
const [deliverableToDelete, setDeliverableToDelete] = useState<number | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState("all");
const [issuesByDeliverable, setIssuesByDeliverable] = useState<Record<number, IssueApi[]>>({});
const currentUser = userSessionService.getSession();
const isAdminRole = currentUser?.roleName && ['CEO', 'COO', 'Admin'].includes(currentUser.roleName);
const [dateFilter, setDateFilter] = useState<string>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [viewMode, setViewMode] = useState<"card" | "table">("table");
// Fetch deliverables
const {
isLoading: isLoadingDeliverables,
error: deliverablesError,
data: deliverables = []
} = useQuery({
queryKey: ['deliverables'],
queryFn: deliverablesApi.getAll,
});
// Fetch projects for dropdown
const {
isLoading: isLoadingProjects,
error: projectsError,
data: projects = []
} = useQuery({
queryKey: ['projects'],
queryFn: projectService.getAll,
});
// Fetch all issues
const {
isLoading: isLoadingIssues,
error: issuesError,
data: allIssues = []
} = useQuery({
queryKey: ['issues'],
queryFn: issuesApi.getAll,
});
// Group issues by deliverable ID
useEffect(() => {
if (allIssues.length > 0) {
const issueGroups = allIssues.reduce<Record<number, IssueApi[]>>((acc, issue) => {
if (issue.deliverablesId) {
if (!acc[issue.deliverablesId]) {
acc[issue.deliverablesId] = [];
}
acc[issue.deliverablesId].push(issue);
}
return acc;
}, {});
setIssuesByDeliverable(issueGroups);
}
}, [allIssues]);
// Delete mutation
const deleteDeliverableMutation = useMutation({
mutationFn: (id: number) => deliverablesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deliverables'] });
toast.success('Deliverable deleted successfully');
setDeliverableToDelete(null);
setDeleteDialogOpen(false);
},
onError: (error) => {
console.error('Delete error:', error);
toast.error('Failed to delete deliverable');
}
});
// Filter deliverables by search query and selected project
const filteredDeliverables = deliverables.filter((deliverable: Deliverable) => {
const matchesSearch =
deliverable.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
deliverable.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesProject =
projectFilter === "all" ||
(projectFilter !== "all" && deliverable.projectId === parseInt(projectFilter));
const matchesStatus =
filterStatus === "all" ||
(deliverable.status && deliverable.status === filterStatus) ||
(!deliverable.status && filterStatus === "Pending");
const matchesDate =
dateFilter === "all" ||
(dateFilter !== "all" && format(new Date(deliverable.deliveryDate), "yyyy-MM") === dateFilter);
return matchesSearch && matchesProject && matchesStatus && matchesDate;
});
// Handle delete deliverable
const handleDeleteDeliverable = (id: number) => {
setDeliverableToDelete(id);
setDeleteDialogOpen(true);
};
// Confirm delete
const confirmDelete = () => {
if (deliverableToDelete) {
deleteDeliverableMutation.mutate(deliverableToDelete);
}
};
// Find project name by ID
const getProjectName = (projectId: number): string => {
const project = projects.find((p: ProjectApi) => p.id === projectId);
return project ? project.projectName : "Unknown Project";
};
// Calculate statistics
const totalDeliverables = deliverables.length;
const upcomingDeliverables = deliverables.filter(
(d) => new Date(d.deliveryDate) > new Date()
).length;
const pastDeliverables = deliverables.filter(
(d) => new Date(d.deliveryDate) <= new Date()
).length;
const completionRate = Math.round((pastDeliverables / (totalDeliverables || 1)) * 100);
// Group deliverables by month
const deliverablesByMonth = filteredDeliverables.reduce<Record<string, Deliverable[]>>((acc, deliverable) => {
const month = format(parseISO(deliverable.deliveryDate), "MMMM yyyy");
if (!acc[month]) {
acc[month] = [];
}
acc[month].push(deliverable);
return acc;
}, {});
const getStatusColor = (date: string): string => {
const today = new Date();
const deliveryDate = new Date(date);
const diffDays = Math.ceil((deliveryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return "bg-red-500/10 text-red-500";
if (diffDays <= 7) return "bg-yellow-500/10 text-yellow-500";
return "bg-green-500/10 text-green-500";
};
// Get issues count for a deliverable
const getIssuesCount = (deliverableId: number): number => {
return issuesByDeliverable[deliverableId]?.length || 0;
};
// Loading state
const isLoading = isLoadingDeliverables || isLoadingProjects || isLoadingIssues;
const clearAllFilters = () => {
setSearchQuery("");
setFilterStatus("all");
setDateFilter("all");
setProjectFilter("all");
};
// Get unique months from deliverables
const getAvailableMonths = () => {
const months: { value: string, label: string }[] = [];
deliverables.forEach(deliverable => {
const date = new Date(deliverable.deliveryDate);
const value = format(date, "yyyy-MM");
const label = format(date, "MMMM yyyy");
const existingMonth = months.find(m => m.value === value);
if (!existingMonth) {
months.push({ value, label });
}
});
return months.sort((a, b) => a.value.localeCompare(b.value));
};
const availableMonths = getAvailableMonths();
const handleProjectChange = (value: string) => {
setProjectFilter(value);
};
const handleStatusChange = (value: string) => {
setFilterStatus(value);
};
const handleDateChange = (value: string) => {
setDateFilter(value);
};
return (
<Layout>
<div className="container mx-auto p-4 md:p-6 space-y-6">
{/* Header Section */}
<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">Deliverables Dashboard</h1>
<p className="text-muted-foreground">
Manage and track all project deliverables
</p>
{currentUser && (
<p className="text-sm text-muted-foreground mt-1">
Logged in as <span className="font-medium">{currentUser.firstName} {currentUser.lastName}</span>
{currentUser.roleName && (
<span> • Role: <span className="font-medium">{currentUser.roleName}</span></span>
)}
{isAdminRole && (
<span><span className="text-green-600">Viewing all deliverables</span></span>
)}
{!isAdminRole && (
<span><span className="text-blue-600">Viewing deliverables from assigned projects only</span></span>
)}
</p>
)}
</div>
<Button
onClick={() => navigate("/new-deliverable")}
className="bg-primary text-white hover:bg-primary/90"
>
Create New Deliverable
</Button>
</div>
{/* Statistics Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20">
<CardContent className="p-3 flex items-center justify-between">
<div>
<p className="text-[10px] font-semibold text-blue-600/70 dark:text-blue-400/70 uppercase tracking-wider">Total Deliverables</p>
<div className="flex items-baseline gap-2">
<h3 className="text-xl font-bold">{totalDeliverables}</h3>
</div>
</div>
<div className="rounded-full p-2 bg-blue-500/10">
<BarChart3 className="h-4 w-4 text-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20">
<CardContent className="p-3 flex items-center justify-between">
<div>
<p className="text-[10px] font-semibold text-green-600/70 dark:text-green-400/70 uppercase tracking-wider">Upcoming</p>
<div className="flex items-baseline gap-2">
<h3 className="text-xl font-bold">{upcomingDeliverables}</h3>
<span className="text-[10px] text-muted-foreground font-medium">
{Math.round((upcomingDeliverables / (totalDeliverables || 1)) * 100)}%
</span>
</div>
</div>
<div className="rounded-full p-2 bg-green-500/10">
<Clock className="h-4 w-4 text-green-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20">
<CardContent className="p-3 flex items-center justify-between">
<div>
<p className="text-[10px] font-semibold text-purple-600/70 dark:text-purple-400/70 uppercase tracking-wider">Completed</p>
<div className="flex items-baseline gap-2">
<h3 className="text-xl font-bold">{pastDeliverables}</h3>
</div>
</div>
<div className="rounded-full p-2 bg-purple-500/10">
<CheckCircle2 className="h-4 w-4 text-purple-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20">
<CardContent className="p-3 flex items-center justify-between">
<div>
<p className="text-[10px] font-semibold text-amber-600/70 dark:text-amber-400/70 uppercase tracking-wider">Completion Rate</p>
<h3 className="text-xl font-bold">{completionRate.toFixed(0)}%</h3>
</div>
<div className="rounded-full p-2 bg-amber-500/10">
<BarChart3 className="h-4 w-4 text-amber-600" />
</div>
</CardContent>
</Card>
</div>
{/* Enhanced Filters */}
<Card className="border border-border shadow-sm bg-accent/5 backdrop-blur-sm">
<CardContent className="p-2">
<div className="flex flex-wrap items-center gap-2">
{/* Search Input */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search deliverables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 h-8 text-sm bg-background border-none shadow-sm"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Status Filter */}
<Dropdown
options={[
{ label: "All Statuses", value: "all" },
{ label: "Pending", value: "Pending" },
{ label: "In Progress", value: "In Progress" },
{ label: "Completed", value: "Completed" },
{ label: "Overdue", value: "Overdue" }
]}
value={filterStatus}
onValueChange={handleStatusChange}
containerClassName="w-auto min-w-[130px]"
className="h-8 border-border bg-background shadow-sm text-xs"
/>
{/* Project Filter */}
<Dropdown
options={[
{ label: "All Projects", value: "all" },
...projects.map((project: ProjectApi) => ({
label: project.projectName,
value: project.id.toString()
}))
]}
value={projectFilter}
onValueChange={handleProjectChange}
containerClassName="w-auto min-w-[130px]"
className="h-8 border-border bg-background shadow-sm text-xs"
/>
{/* Date Filter */}
<Dropdown
options={[
{ label: "All Months", value: "all" },
...availableMonths.map((month) => ({
label: month.label,
value: month.value
}))
]}
value={dateFilter}
onValueChange={handleDateChange}
containerClassName="w-auto min-w-[130px]"
className="h-8 border-border bg-background shadow-sm text-xs"
/>
{/* Clear Filters Button */}
{(searchQuery || filterStatus !== "all" || projectFilter !== "all" || dateFilter !== "all") && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={clearAllFilters}
>
<X className="h-3 w-3 mr-1" />
Clear
</Button>
)}
<div className="h-6 w-px bg-border/50 mx-1" />
{/* View Toggle */}
<div className="flex rounded-md bg-background shadow-sm h-8 p-0.5 border border-border">
<Button
variant={viewMode === "card" ? "default" : "ghost"}
size="icon"
className={`h-full w-7 rounded-sm ${viewMode === "card" ? "bg-[#ff6600] hover:bg-orange-600 text-white" : ""}`}
onClick={() => setViewMode("card")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="icon"
className={`h-full w-7 rounded-sm ${viewMode === "table" ? "bg-[#ff6600] hover:bg-orange-600 text-white" : ""}`}
onClick={() => setViewMode("table")}
>
<Table2 className="h-3.5 w-3.5" />
</Button>
</div>
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={() => navigate("/new-deliverable")}
>
<Plus className="h-3.5 w-3.5 mr-1" /> New
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Deliverables List */}
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
</div>
</CardContent>
</Card>
))}
</div>
) : deliverablesError || projectsError || issuesError ? (
<div className="flex items-center justify-center py-8">
<p className="text-red-500">Error loading data</p>
</div>
) : filteredDeliverables.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 gap-2">
<p className="text-muted-foreground">No deliverables found</p>
<Button
variant="outline"
onClick={() => navigate("/new-deliverable")}
className="mt-2"
>
<Plus className="h-4 w-4 mr-2" />
Add your first deliverable
</Button>
</div>
) : viewMode === "card" ? (
<div className="space-y-6">
{Object.entries(deliverablesByMonth).map(([month, monthDeliverables]) => (
<div key={month}>
<h2 className="text-lg font-semibold mb-3">{month}</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{monthDeliverables.map((deliverable: Deliverable) => {
const deliveryDate = new Date(deliverable.deliveryDate);
const issuesCount = getIssuesCount(deliverable.id);
const getStatusBadge = () => {
if (deliverable.status === "Overdue") {
return <Badge variant="destructive">Overdue</Badge>;
} else if (deliverable.status === "Completed") {
return <Badge className="bg-green-500/10 text-green-500">Completed</Badge>;
} else if (deliverable.status === "In Progress") {
return <Badge className="bg-blue-500/10 text-blue-500">In Progress</Badge>;
} else {
return <Badge className="bg-yellow-500/10 text-yellow-500">{deliverable.status || "Pending"}</Badge>;
}
};
return (
<Card
key={deliverable.id}
className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/deliverable-details/${deliverable.id}`)}
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="text-lg line-clamp-1">
{deliverable.name}
</CardTitle>
<CardDescription>
Project #{deliverable.projectId}
</CardDescription>
</div>
{getStatusBadge()}
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{deliverable.description || "No description provided"}
</p>
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
{format(deliveryDate, "MMM d, yyyy")}
</span>
</div>
<Badge variant="secondary">
{issuesCount} {issuesCount === 1 ? 'issue' : 'issues'}
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
))}
</div>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Name</TableHead>
<TableHead>Project</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-[130px]">Delivery Date</TableHead>
<TableHead className="w-[100px] text-center">Issues</TableHead>
<TableHead className="w-[120px]">Status</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDeliverables.map((deliverable: Deliverable) => {
const deliveryDate = new Date(deliverable.deliveryDate);
const isOverdue = deliveryDate < new Date();
const statusColor = getStatusColor(deliverable.deliveryDate);
const issuesCount = getIssuesCount(deliverable.id);
return (
<TableRow
key={deliverable.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => navigate(`/deliverable-details/${deliverable.id}`)}
>
<TableCell className="font-medium">
{deliverable.name}
</TableCell>
<TableCell>
{getProjectName(deliverable.projectId)}
</TableCell>
<TableCell className="max-w-[300px]">
<div className="truncate">
{deliverable.description || "No description provided"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
{format(deliveryDate, "MMM d, yyyy")}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{issuesCount}
</Badge>
</TableCell>
<TableCell>
{deliverable.status === "Overdue" ? (
<Badge variant="destructive" className="text-xs">
Overdue
</Badge>
) : deliverable.status === "Completed" ? (
<Badge className="bg-green-500/10 text-green-500">
Completed
</Badge>
) : deliverable.status === "In Progress" ? (
<Badge className="bg-blue-500/10 text-blue-500">
In Progress
</Badge>
) : (
<Badge className="bg-yellow-500/10 text-yellow-500">
{deliverable.status || "Pending"}
</Badge>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/deliverable-details/${deliverable.id}`);
}}
>
<Edit className="h-4 w-4 mr-2" />
View Details
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDeleteDeliverable(deliverable.id);
}}
className="text-destructive"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the deliverable.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Layout>
);
};
export default DeliverablesList;