diff --git a/apps/console/src/pages/FrameworkOverviewPage.tsx b/apps/console/src/pages/FrameworkOverviewPage.tsx index 160ac08e5..e51d34304 100644 --- a/apps/console/src/pages/FrameworkOverviewPage.tsx +++ b/apps/console/src/pages/FrameworkOverviewPage.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect, useState, useRef } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useParams, useNavigate, Link } from "react-router"; import { graphql, @@ -6,9 +6,19 @@ import { usePreloadedQuery, useQueryLoader, } from "react-relay"; -import { Shield, MoveUpRight, Plus } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Plus, + ShieldCheck, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; import type { FrameworkOverviewPageQuery as FrameworkOverviewPageQueryType } from "./__generated__/FrameworkOverviewPageQuery.graphql"; import { Helmet } from "react-helmet-async"; import { PageHeader } from "@/components/PageHeader"; @@ -38,98 +48,23 @@ const FrameworkOverviewPageQuery = graphql` } `; -function ControlSquare({ - control, - onClick, -}: { - control: { - id?: string; - name?: string; - description?: string; - state?: string; - category?: string; - importance?: string; - }; - onClick: () => void; -}) { - const [showTooltip, setShowTooltip] = useState(false); - const squareRef = useRef(null); - const tooltipRef = useRef(null); +// Define control type for better type safety +interface Control { + id?: string; + name?: string; + description?: string; + state?: string; + category?: string; + importance?: string; + status?: string; +} - const truncateDescription = (text?: string, maxLength = 100) => { - if (!text) return ""; - return text.length > maxLength - ? `${text.substring(0, maxLength)}...` - : text; - }; - - // Calculate tooltip position when it becomes visible - useEffect(() => { - if (showTooltip && squareRef.current && tooltipRef.current) { - const squareRect = squareRef.current.getBoundingClientRect(); - const tooltipRect = tooltipRef.current.getBoundingClientRect(); - - // Position tooltip above the square - const top = squareRect.top - tooltipRect.height - 10; - const left = - squareRect.left + squareRect.width / 2 - tooltipRect.width / 2; - - // Adjust if tooltip would go off screen - const adjustedLeft = Math.max( - 10, - Math.min(left, window.innerWidth - tooltipRect.width - 10) - ); - - tooltipRef.current.style.top = `${top}px`; - tooltipRef.current.style.left = `${adjustedLeft}px`; - } - }, [showTooltip]); - - return ( -
-
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - onClick={onClick} - /> - - {showTooltip && ( -
-
-
-
- 30 min -
-
- {control?.importance} -
-
- -
-
{control?.name}
-
- {truncateDescription(control?.description)} -
-
-
-
-
- - {control?.state === "IMPLEMENTED" ? "Validated" : "Not validated"} - -
-
- )} -
- ); +interface Category { + id: string; + name: string; + description: string; + progress: number; + controls: Control[]; } function FrameworkOverviewPageContent({ @@ -143,41 +78,100 @@ function FrameworkOverviewPageContent({ const navigate = useNavigate(); const { organizationId } = useParams(); + const [expandedCategories, setExpandedCategories] = useState([]); + + // Map control state to status for the new design + const mapStateToStatus = (state?: string): string => { + if (!state) return "incomplete"; + switch (state) { + case "IMPLEMENTED": + return "complete"; + case "NOT_APPLICABLE": + return "not-applicable"; + case "NOT_STARTED": + return "not-started"; + default: + return "in-progress"; + } + }; + + // Group controls by category const controlsByCategory = controls.reduce((acc, control) => { if (!control?.category) return acc; if (!acc[control.category]) { acc[control.category] = []; } - acc[control.category].push(control); + acc[control.category].push({ + ...control, + status: mapStateToStatus(control.state), + }); return acc; - }, {} as Record); + }, {} as Record); - const controlCards = Object.entries(controlsByCategory) - .sort(([categoryA], [categoryB]) => categoryA.localeCompare(categoryB)) - .map(([category, controls]) => { - const sortedControls = [...controls].sort((a, b) => { - if (a?.state === "IMPLEMENTED" && b?.state !== "IMPLEMENTED") return -1; - if (a?.state !== "IMPLEMENTED" && b?.state === "IMPLEMENTED") return 1; + // Toggle category expansion + const toggleCategory = (categoryId: string) => { + setExpandedCategories((prev) => + prev.includes(categoryId) + ? prev.filter((id) => id !== categoryId) + : [...prev, categoryId] + ); + }; - return (a?.name || "").localeCompare(b?.name || ""); - }); + // Process categories with their controls + const categories: Category[] = Object.entries(controlsByCategory) + .map(([categoryName, categoryControls]) => { + // Calculate progress + const implementedCount = categoryControls.filter( + (control) => control.status === "complete" + ).length; + const applicableCount = categoryControls.filter( + (control) => control.status !== "not-applicable" + ).length; + const progress = applicableCount + ? Math.round((implementedCount / applicableCount) * 100) + : 0; return { - title: category, - controls: sortedControls, - completed: controls.filter((c) => c?.state === "IMPLEMENTED").length, - total: controls.length, + id: categoryName, + name: categoryName, + description: `Controls related to ${categoryName.toLowerCase()}`, + progress: progress, + controls: categoryControls, }; - }); + }) + .filter((category) => category.controls.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)); - const totalImplemented = controls.filter( - (c) => c?.state === "IMPLEMENTED" - ).length; + const getStatusIcon = (status: string) => { + switch (status) { + case "complete": + return ; + case "in-progress": + return ; + case "not-started": + return ; + case "incomplete": + return ; + case "not-applicable": + return ; + default: + return null; + } + }; + + const getStatusCounts = (controls: Control[]) => { + return controls.reduce((acc, control) => { + if (control.status) { + acc[control.status] = (acc[control.status] || 0) + 1; + } + return acc; + }, {} as Record); + }; return (
-
-

Controls

-
- {totalImplemented} out of {controls.length} validated -
-
+
+ {categories.map((category) => { + const isExpanded = expandedCategories.includes(category.id); + const statusCounts = getStatusCounts(category.controls); -
- {controlCards.map((card, index) => ( -
-
-
-
- - {card.title} -
-
-
-
-
- {Array(card.total) - .fill(0) - .map((_, i) => { - const control = card.controls[i]; - return ( - { - if (control?.id) { - navigate( - `/organizations/${organizationId}/frameworks/${framework.id}/controls/${control.id}` - ); - } - }} - /> - ); - })} + return ( +
+ + +
+
+ {category.name} +
+
+ + {statusCounts.complete || 0} Complete + + + {statusCounts["in-progress"] || 0} In Progress + + + {statusCounts.incomplete || 0} Incomplete + +
-
-
- - {card.completed}/{card.total} Controls validated - -
-
+
+
+ Implementation progress + {category.progress}% +
+ +
+ + + + + {isExpanded && ( + + {category.controls.length > 0 ? ( +
+ + + + + + + + + + {category.controls.map((control) => ( + { + if (control?.id) { + navigate( + `/organizations/${organizationId}/frameworks/${framework.id}/controls/${control.id}` + ); + } + }} + > + + + + + ))} + +
+ Importance + + Status + Control
+ + {control.importance} + + +
+ {control.status + ? getStatusIcon(control.status) + : null} +
+
+
+ {control.name} +
+
+ {control.description} +
+
+
+ ) : ( +
+ No controls in this category +
+ )} +
+ )} +
-
- ))} + ); + })}
);