From c566a01a86beb32b91e4ae3fd87dca57860d90a0 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 19 Aug 2025 17:32:38 -0400 Subject: [PATCH 1/3] feat: enhance framework compliance tracking and UI - Added functionality to calculate compliance scores for frameworks, integrating compliance data into the dashboard. - Updated FrameworkCard to display detailed breakdowns of policies and tasks, improving user insights. - Refactored FrameworkList and FrameworksOverview components to utilize new compliance data structure. - Introduced getFrameworkWithComplianceScores function to streamline compliance calculations. --- .../frameworks/components/FrameworkCard.tsx | 42 ++++++++--- .../frameworks/components/FrameworkList.tsx | 12 ++-- .../components/FrameworksOverview.tsx | 11 ++- .../data/getFrameworkWithComplianceScores.ts | 71 +++++++------------ .../src/app/(app)/[orgId]/frameworks/page.tsx | 7 ++ 5 files changed, 79 insertions(+), 64 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx index 86de5fd77..6b4b7f8c1 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx @@ -52,7 +52,15 @@ export function FrameworkCard({ }; const controlsCount = frameworkInstance.controls?.length || 0; - const compliantControlsCount = Math.round((complianceScore / 100) * controlsCount); + // Policies breakdown + const frameworkControlIds = frameworkInstance.controls?.map((c) => c.id) || []; + const allPolicies = + frameworkInstance.controls?.flatMap((control) => control.policies || []) || []; + const uniquePoliciesMap = new Map(); + for (const p of allPolicies) uniquePoliciesMap.set(p.id, p); + const uniquePolicies = Array.from(uniquePoliciesMap.values()); + const totalPolicies = uniquePolicies.length; + const publishedPolicies = uniquePolicies.filter((p) => p.status === 'published').length; // Calculate not started controls: controls where all policies are draft or non-existent AND all tasks are todo or non-existent const notStartedControlsCount = @@ -78,11 +86,12 @@ export function FrameworkCard({ // If either any policy is not draft or any task is not todo, it's in progress }).length || 0; - // Calculate in progress controls: Total - Compliant - Not Started - const inProgressCount = Math.max( - 0, // Ensure count doesn't go below zero - controlsCount - compliantControlsCount - notStartedControlsCount, + // Tasks breakdown for this framework (tasks associated with its controls) + const frameworkTasks = tasks.filter((task) => + task.controls.some((c) => frameworkControlIds.includes(c.id)), ); + const totalTasks = frameworkTasks.length; + const doneTasks = frameworkTasks.filter((t) => t.status === 'done').length; // Use direct framework data: const frameworkDetails = frameworkInstance.framework; @@ -129,11 +138,24 @@ export function FrameworkCard({ - {/* Stats */} -
- {compliantControlsCount} complete - {inProgressCount} active - {controlsCount} total + {/* Breakdown */} +
+
+ Policies + + {publishedPolicies}/{totalPolicies} published + +
+
+ Tasks + + {doneTasks}/{totalTasks} done + +
+
+ Controls + {controlsCount} total +
{/* Footer */} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx index 5ad5b796f..36b5281ac 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx @@ -1,25 +1,25 @@ 'use client'; import { Control, Task } from '@db'; -import type { FrameworkInstanceWithControls } from '../types'; import { FrameworkCard } from './FrameworkCard'; +import type { FrameworkInstanceWithComplianceScore } from './types'; export function FrameworkList({ - frameworksWithControls, + frameworksWithCompliance, tasks, }: { - frameworksWithControls: FrameworkInstanceWithControls[]; + frameworksWithCompliance: FrameworkInstanceWithComplianceScore[]; tasks: (Task & { controls: Control[] })[]; }) { - if (!frameworksWithControls.length) return null; + if (!frameworksWithCompliance.length) return null; return (
- {frameworksWithControls.map((frameworkInstance) => ( + {frameworksWithCompliance.map(({ frameworkInstance, complianceScore }) => ( ))} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx index 74b3822b1..8ff594329 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx @@ -10,17 +10,20 @@ import { useState } from 'react'; import type { FrameworkInstanceWithControls } from '../types'; import { AddFrameworkModal } from './AddFrameworkModal'; import { FrameworkList } from './FrameworkList'; +import type { FrameworkInstanceWithComplianceScore } from './types'; export interface FrameworksOverviewProps { frameworksWithControls: FrameworkInstanceWithControls[]; tasks: (Task & { controls: Control[] })[]; allFrameworks: FrameworkEditorFramework[]; + frameworksWithCompliance?: FrameworkInstanceWithComplianceScore[]; } export function FrameworksOverview({ frameworksWithControls, tasks, allFrameworks, + frameworksWithCompliance, }: FrameworksOverviewProps) { const params = useParams<{ orgId: string }>(); const organizationId = params.orgId; @@ -34,7 +37,13 @@ export function FrameworksOverview({ return (
- + ({ frameworkInstance: fw, complianceScore: 0 })) + } + tasks={tasks} + />
-
- Controls - {controlsCount} total -
+ {/* Intentionally omit controls in card to reduce noise */}
{/* Footer */} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts b/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts index ed14ab034..d6a5c324a 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts @@ -1,15 +1,9 @@ 'use server'; -import { Control, type PolicyStatus, type Task } from '@db'; +import { Control, type Task } from '@db'; import { FrameworkInstanceWithComplianceScore } from '../components/types'; -import { FrameworkInstanceWithControls } from '../types'; // This now has policies with selected fields - -// Define the type for the policies array based on the select in FrameworkInstanceWithControls -type SelectedPolicy = { - id: string; - name: string; - status: PolicyStatus; -}; +import { computeFrameworkStats } from '../lib/compute'; +import { FrameworkInstanceWithControls } from '../types'; /** * Gets all framework instances for an organization with compliance calculations @@ -20,39 +14,15 @@ export async function getFrameworkWithComplianceScores({ frameworksWithControls, tasks, }: { - frameworksWithControls: FrameworkInstanceWithControls[]; // This type defines control.policies as SelectedPolicy[] + frameworksWithControls: FrameworkInstanceWithControls[]; tasks: (Task & { controls: Control[] })[]; }): Promise { - // Calculate compliance for each framework const frameworksWithComplianceScores = frameworksWithControls.map((frameworkInstance) => { - // Flatten all policies across controls for this framework instance - const controls = frameworkInstance.controls; - const allPolicies: SelectedPolicy[] = controls.flatMap( - (control) => (control.policies as SelectedPolicy[]) ?? [], - ); - // Deduplicate policies by id to avoid counting the same policy mapped to multiple controls - const uniquePoliciesMap = new Map(); - for (const p of allPolicies) uniquePoliciesMap.set(p.id, p); - const uniquePolicies = Array.from(uniquePoliciesMap.values()); - const totalPolicies = uniquePolicies.length; - const publishedPolicies = uniquePolicies.filter((p) => p.status === 'published').length; - const policyRatio = totalPolicies > 0 ? publishedPolicies / totalPolicies : 0; - - // Tasks breakdown for this framework instance - const controlIds = controls.map((c) => c.id); - const frameworkTasks = tasks.filter((task) => - task.controls.some((c) => controlIds.includes(c.id)), - ); - const totalTasks = frameworkTasks.length; - const doneTasks = frameworkTasks.filter((t) => t.status === 'done').length; - const taskRatio = totalTasks > 0 ? doneTasks / totalTasks : 1; - - // Blend policy and task progress equally - const compliance = Math.round(((policyRatio + taskRatio) / 2) * 100); + const { complianceScore } = computeFrameworkStats(frameworkInstance, tasks); return { frameworkInstance, - complianceScore: compliance, + complianceScore: complianceScore, }; }); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts b/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts new file mode 100644 index 000000000..be538b21a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts @@ -0,0 +1,46 @@ +import type { Control, Task } from '@db'; +import type { FrameworkInstanceWithControls } from '../types'; + +export interface FrameworkStats { + totalPolicies: number; + publishedPolicies: number; + totalTasks: number; + doneTasks: number; + controlsCount: number; + complianceScore: number; // 0-100 +} + +export function computeFrameworkStats( + frameworkInstance: FrameworkInstanceWithControls, + tasks: (Task & { controls: Control[] })[], +): FrameworkStats { + const controls = frameworkInstance.controls ?? []; + const controlsCount = controls.length; + + // Deduplicate policies by id across all controls + const allPolicies = controls.flatMap((c) => c.policies || []); + const uniquePoliciesMap = new Map(); + for (const p of allPolicies) uniquePoliciesMap.set(p.id, p as any); + const uniquePolicies = Array.from(uniquePoliciesMap.values()); + + const totalPolicies = uniquePolicies.length; + const publishedPolicies = uniquePolicies.filter((p) => p.status === 'published').length; + const policyRatio = totalPolicies > 0 ? publishedPolicies / totalPolicies : 0; + + const controlIds = controls.map((c) => c.id); + const frameworkTasks = tasks.filter((t) => t.controls.some((c) => controlIds.includes(c.id))); + const totalTasks = frameworkTasks.length; + const doneTasks = frameworkTasks.filter((t) => t.status === 'done').length; + const taskRatio = totalTasks > 0 ? doneTasks / totalTasks : 1; + + const complianceScore = Math.round(((policyRatio + taskRatio) / 2) * 100); + + return { + totalPolicies, + publishedPolicies, + totalTasks, + doneTasks, + controlsCount, + complianceScore, + }; +} From 09f52cffd704e0c1bbe26c9a2885f9e19e521154 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 19 Aug 2025 17:52:30 -0400 Subject: [PATCH 3/3] feat: implement DynamicMinHeight component for responsive layout - Added a new `DynamicMinHeight` component to calculate and set the minimum height dynamically based on header and onboarding banner heights. - Replaced static height calculations in the layout with the new component to enhance responsiveness. - Updated `OnboardingTracker` component to include an ID for the onboarding banner, allowing for accurate height calculations. --- .../[orgId]/components/DynamicMinHeight.tsx | 53 +++++++++++++++++++ .../[orgId]/components/OnboardingTracker.tsx | 5 +- apps/app/src/app/(app)/[orgId]/layout.tsx | 16 +----- 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx diff --git a/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx b/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx new file mode 100644 index 000000000..d45fa4553 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { cn } from '@comp/ui/cn'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +interface DynamicMinHeightProps { + children: React.ReactNode; + className?: string; +} + +export function DynamicMinHeight({ children, className }: DynamicMinHeightProps) { + const containerRef = useRef(null); + const [offsetPx, setOffsetPx] = useState(0); + + useEffect(() => { + const headerEl = document.querySelector('header.sticky') as HTMLElement | null; + const bannerEl = document.getElementById('onboarding-banner') as HTMLElement | null; + + const compute = () => { + const header = headerEl?.offsetHeight ?? 0; + const banner = bannerEl?.offsetHeight ?? 0; + // Add 1px border for each element like the server calculation did + const extra = 0; // borders already included in offsetHeight + setOffsetPx(header + banner + extra); + }; + + compute(); + + const resizeObserver = new ResizeObserver(() => compute()); + if (headerEl) resizeObserver.observe(headerEl); + if (bannerEl) resizeObserver.observe(bannerEl); + + const onResize = () => compute(); + window.addEventListener('resize', onResize); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', onResize); + }; + }, []); + + const style = useMemo(() => ({ minHeight: `calc(100vh - ${offsetPx}px)` }), [offsetPx]); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx index 987b15f8d..28fdcc4f3 100644 --- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx @@ -197,7 +197,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => } return ( - +
{renderStatusContent()}
diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 2f4879ced..ec730a09c 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -11,6 +11,7 @@ import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { Suspense } from 'react'; +import { DynamicMinHeight } from './components/DynamicMinHeight'; import { OnboardingTracker } from './components/OnboardingTracker'; const HotKeys = dynamic(() => import('@/components/hot-keys').then((mod) => mod.HotKeys), { @@ -57,8 +58,6 @@ export default async function Layout({ }, }); - console.log('member', member); - if (!member) { // User doesn't have access to this organization return redirect('/auth/unauthorized'); @@ -80,12 +79,6 @@ export default async function Layout({ }, }); - const isOnboardingRunning = !!onboarding?.triggerJobId && !onboarding.triggerJobCompleted; - const navbarHeight = 53 + 1; // 1 for border - const onboardingHeight = 132 + 1; // 1 for border - - const pixelsOffset = isOnboardingRunning ? navbarHeight + onboardingHeight : navbarHeight; - return ( } isCollapsed={isCollapsed}> {onboarding?.triggerJobId && }
-
- {children} -
+ {children}