Make progress more visible for a framework

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-21 14:42:04 +01:00
parent 825548d87d
commit 961a460b55

View File

@@ -18,7 +18,6 @@ import {
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 { FrameworkViewQuery as FrameworkViewQueryType } from "./__generated__/FrameworkViewQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { FrameworkViewSkeleton } from "./FrameworkPage";
@@ -47,7 +46,6 @@ const FrameworkViewQuery = graphql`
}
`;
// Define control type for better type safety
interface Control {
id?: string;
name?: string;
@@ -64,6 +62,8 @@ interface Category {
description: string;
progress: number;
controls: Control[];
doneCount: number;
totalCount: number;
}
function FrameworkViewContent({
@@ -94,20 +94,44 @@ function FrameworkViewContent({
}
};
const processedControls = controls.map((control) => ({
...control,
status: mapStateToStatus(control.state),
}));
// Calculate global progress
const implementedCount = processedControls.filter(
(control) => control.status === "complete"
).length;
const notApplicableCount = processedControls.filter(
(control) => control.status === "not-applicable"
).length;
const totalControls = processedControls.length;
// Include not-applicable as effectively "complete" for progress percentage
const effectiveCompletedCount = implementedCount + notApplicableCount;
const globalProgress = totalControls
? Math.round((effectiveCompletedCount / totalControls) * 100)
: 0;
// Get global status counts
const globalStatusCounts = processedControls.reduce((acc, control) => {
if (control.status) {
acc[control.status] = (acc[control.status] || 0) + 1;
}
return acc;
}, {} as Record<string, number>);
// Group controls by category
const controlsByCategory = controls.reduce((acc, control) => {
const controlsByCategory = processedControls.reduce((acc, control) => {
if (!control?.category) return acc;
if (!acc[control.category]) {
acc[control.category] = [];
}
acc[control.category].push({
...control,
status: mapStateToStatus(control.state),
});
acc[control.category].push(control);
return acc;
}, {} as Record<string, Control[]>);
// Toggle category expansion
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) =>
prev.includes(categoryId)
@@ -116,18 +140,19 @@ function FrameworkViewContent({
);
};
// Process categories with their controls
const categories: Category[] = Object.entries(controlsByCategory)
.map(([categoryName, categoryControls]) => {
// Calculate progress
const implementedCount = categoryControls.filter(
const catImplementedCount = categoryControls.filter(
(control) => control.status === "complete"
).length;
const applicableCount = categoryControls.filter(
(control) => control.status !== "not-applicable"
const catNotApplicableCount = categoryControls.filter(
(control) => control.status === "not-applicable"
).length;
const progress = applicableCount
? Math.round((implementedCount / applicableCount) * 100)
// Consider both "complete" and "not-applicable" as done for category progress
const catDoneCount = catImplementedCount + catNotApplicableCount;
const catTotalCount = categoryControls.length;
const progress = catTotalCount
? Math.round((catDoneCount / catTotalCount) * 100)
: 0;
return {
@@ -136,6 +161,8 @@ function FrameworkViewContent({
description: `Controls related to ${categoryName.toLowerCase()}`,
progress: progress,
controls: categoryControls,
doneCount: catDoneCount,
totalCount: catTotalCount,
};
})
.filter((category) => category.controls.length > 0)
@@ -146,27 +173,18 @@ function FrameworkViewContent({
case "complete":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "in-progress":
return <Clock className="h-5 w-5 text-amber-500" />;
return <Clock className="h-5 w-5 text-blue-500" />;
case "not-started":
return <AlertCircle className="h-5 w-5 text-blue-500" />;
return <AlertCircle className="h-5 w-5 text-gray-200" />;
case "incomplete":
return <AlertCircle className="h-5 w-5 text-red-500" />;
case "not-applicable":
return <ShieldCheck className="h-5 w-5 text-gray-400" />;
return <ShieldCheck className="h-5 w-5 text-gray-600" />;
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<string, number>);
};
return (
<PageTemplate
title={framework.name ?? ""}
@@ -191,10 +209,117 @@ function FrameworkViewContent({
</div>
}
>
{/* Global Progress Summary */}
<div className="mb-8">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-medium">Framework Implementation</h3>
<span className="text-sm text-muted-foreground">
{globalProgress}% complete
</span>
</div>
{/* Progress bar container */}
<div className="w-full h-5 rounded-full overflow-hidden bg-muted mb-2">
{/* Segmented progress bar */}
<div className="flex h-full">
{/* Complete segment */}
{globalStatusCounts.complete > 0 && (
<div
className="bg-green-500 h-full"
style={{
width: `${
(globalStatusCounts.complete / totalControls) * 100
}%`,
}}
/>
)}
{/* In-progress segment */}
{globalStatusCounts["in-progress"] > 0 && (
<div
className="bg-blue-500 h-full"
style={{
width: `${
(globalStatusCounts["in-progress"] / totalControls) * 100
}%`,
}}
/>
)}
{/* Incomplete segment */}
{globalStatusCounts.incomplete > 0 && (
<div
className="bg-red-500 h-full"
style={{
width: `${
(globalStatusCounts.incomplete / totalControls) * 100
}%`,
}}
/>
)}
{/* Not applicable segment */}
{globalStatusCounts["not-applicable"] > 0 && (
<div
className="bg-gray-600 h-full"
style={{
width: `${
(globalStatusCounts["not-applicable"] / totalControls) * 100
}%`,
}}
/>
)}
{/* Not started segment */}
{globalStatusCounts["not-started"] > 0 && (
<div
className="bg-gray-200 h-full"
style={{
width: `${
(globalStatusCounts["not-started"] / totalControls) * 100
}%`,
}}
/>
)}
</div>
</div>
{/* Status legend - reorder to match progress bar */}
<div className="flex flex-wrap items-center gap-4 text-sm">
{globalStatusCounts.complete > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span>Complete ({globalStatusCounts.complete})</span>
</div>
)}
{globalStatusCounts["in-progress"] > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span>In Progress ({globalStatusCounts["in-progress"]})</span>
</div>
)}
{globalStatusCounts.incomplete > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span>Incomplete ({globalStatusCounts.incomplete})</span>
</div>
)}
{globalStatusCounts["not-applicable"] > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="w-3 h-3 rounded-full bg-gray-600"></div>
<span>
Not Applicable ({globalStatusCounts["not-applicable"]})
</span>
</div>
)}
{globalStatusCounts["not-started"] > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="w-3 h-3 rounded-full bg-gray-200"></div>
<span>Not Started ({globalStatusCounts["not-started"]})</span>
</div>
)}
</div>
</div>
<div className="grid gap-6">
{categories.map((category) => {
const isExpanded = expandedCategories.includes(category.id);
const statusCounts = getStatusCounts(category.controls);
return (
<div
@@ -207,34 +332,12 @@ function FrameworkViewContent({
<div className="flex items-center gap-2">
<CardTitle>{category.name}</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-green-500/10 text-green-500"
>
{statusCounts.complete || 0} Complete
</Badge>
<Badge
variant="outline"
className="bg-amber-500/10 text-amber-500"
>
{statusCounts["in-progress"] || 0} In Progress
</Badge>
<Badge
variant="outline"
className="bg-red-500/10 text-red-500"
>
{statusCounts.incomplete || 0} Incomplete
</Badge>
<div className="flex items-center text-sm text-muted-foreground">
<span>
{category.doneCount} / {category.totalCount}
</span>
</div>
</div>
<div className="mt-2">
<div className="mb-1 flex items-center justify-between text-sm">
<span>Implementation progress</span>
<span className="font-medium">{category.progress}%</span>
</div>
<Progress value={category.progress} className="h-2" />
</div>
<Button
variant="outline"