Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const [offsetPx, setOffsetPx] = useState<number>(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 (
<div
ref={containerRef}
className={cn('textured-background mx-auto px-4 py-4', className)}
style={style}
>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
}

return (
<Card className="w-full overflow-hidden rounded-none border-x-0 border-t-0">
<Card
id="onboarding-banner"
className="w-full overflow-hidden rounded-none border-x-0 border-t-0"
>
<CardContent className="bg-background flex flex-col items-center justify-center">
<div className="w-full pt-4">{renderStatusContent()}</div>
</CardContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Control, Task } from '@db';
import { BarChart3, Clock } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { computeFrameworkStats } from '../lib/compute';
import type { FrameworkInstanceWithControls } from '../types';

interface FrameworkCardProps {
Expand Down Expand Up @@ -51,44 +52,13 @@ export function FrameworkCard({
return 'text-red-600 dark:text-red-400';
};

const controlsCount = frameworkInstance.controls?.length || 0;
const compliantControlsCount = Math.round((complianceScore / 100) * controlsCount);

// Calculate not started controls: controls where all policies are draft or non-existent AND all tasks are todo or non-existent
const notStartedControlsCount =
frameworkInstance.controls?.filter((control) => {
// If a control has no policies and no tasks, it's not started.
const controlTasks = tasks.filter((task) => task.controls.some((c) => c.id === control.id));

if ((!control.policies || control.policies.length === 0) && controlTasks.length === 0) {
return true;
}

// Check if ALL policies are in draft state or non-existent
const policiesNotStarted =
!control.policies ||
control.policies.length === 0 ||
control.policies.every((policy) => policy.status === 'draft');

// Check if ALL tasks are in todo state or there are no tasks
const tasksNotStarted =
controlTasks.length === 0 || controlTasks.every((task) => task.status === 'todo');

return policiesNotStarted && tasksNotStarted;
// 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,
const { totalPolicies, publishedPolicies, totalTasks, doneTasks } = computeFrameworkStats(
frameworkInstance,
tasks,
);

// Use direct framework data:
const frameworkDetails = frameworkInstance.framework;
const statusBadge = getStatusBadge(complianceScore);

// Calculate last activity date - use current date as fallback
const lastActivityDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
Expand Down Expand Up @@ -129,11 +99,21 @@ export function FrameworkCard({
<Progress value={complianceScore} className="h-1" />
</div>

{/* Stats */}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>{compliantControlsCount} complete</span>
<span>{inProgressCount} active</span>
<span>{controlsCount} total</span>
{/* Breakdown */}
<div className="text-muted-foreground space-y-1.5 text-xs">
<div className="flex items-center justify-between">
<span>Policies</span>
<span className="tabular-nums">
{publishedPolicies}/{totalPolicies} published
</span>
</div>
<div className="flex items-center justify-between">
<span>Tasks</span>
<span className="tabular-nums">
{doneTasks}/{totalTasks} done
</span>
</div>
{/* Intentionally omit controls in card to reduce noise */}
</div>

{/* Footer */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
{frameworksWithControls.map((frameworkInstance) => (
{frameworksWithCompliance.map(({ frameworkInstance, complianceScore }) => (
<FrameworkCard
key={frameworkInstance.id}
frameworkInstance={frameworkInstance}
complianceScore={0}
complianceScore={complianceScore}
tasks={tasks}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,7 +37,13 @@ export function FrameworksOverview({
return (
<div className="space-y-4">
<div className="grid w-full gap-4 select-none md:grid-cols-1">
<FrameworkList frameworksWithControls={frameworksWithControls} tasks={tasks} />
<FrameworkList
frameworksWithCompliance={
frameworksWithCompliance ??
frameworksWithControls.map((fw) => ({ frameworkInstance: fw, complianceScore: 0 }))
}
tasks={tasks}
/>
<div className="flex items-center justify-center">
<Button onClick={() => setIsAddFrameworkModalOpen(true)} variant="outline">
{'Add Framework'} <PlusIcon className="h-4 w-4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,9 @@
'use server';

import {
Control, // Policy might still be useful if full Policy objects were ever passed, but selected fields are more common now
type PolicyStatus, // For the selected policy type
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;
};

/**
* Checks if a control is compliant based on its policies and tasks
* @param policies - The policies to check (selected fields)
* @param tasks - The tasks to check
* @returns boolean indicating if all policies and tasks are compliant
*/
const isControlCompliant = (
policies: SelectedPolicy[], // Use the specific selected type
tasks: Task[],
) => {
// If there are no policies, the control is not compliant (or has no policy evidence)
if (!policies || policies.length === 0) {
// Depending on business logic, an empty policies array might mean non-compliant or N/A.
// For now, sticking to original logic of false if empty.
return false;
}

const totalPolicies = policies.length;
const completedPolicies = policies.filter((policy) => {
return policy.status === 'published'; // Directly check status of the selected policy
}).length;

const totalTasks = tasks.length;
const completedTasks = tasks.filter((task) => task.status === 'done').length;

return completedPolicies === totalPolicies && (totalTasks === 0 || completedTasks === totalTasks);
};
import { computeFrameworkStats } from '../lib/compute';
import { FrameworkInstanceWithControls } from '../types';

/**
* Gets all framework instances for an organization with compliance calculations
Expand All @@ -52,30 +14,15 @@ export async function getFrameworkWithComplianceScores({
frameworksWithControls,
tasks,
}: {
frameworksWithControls: FrameworkInstanceWithControls[]; // This type defines control.policies as SelectedPolicy[]
frameworksWithControls: FrameworkInstanceWithControls[];
tasks: (Task & { controls: Control[] })[];
}): Promise<FrameworkInstanceWithComplianceScore[]> {
// Calculate compliance for each framework
const frameworksWithComplianceScores = frameworksWithControls.map((frameworkInstance) => {
// Get all controls for this framework
const controls = frameworkInstance.controls;

console.log({ controls });

// Calculate compliance percentage
const totalControls = controls.length;
const compliantControls = controls.filter((control) => {
const controlTasks = tasks.filter((task) => task.controls.some((c) => c.id === control.id));
// control.policies here matches SelectedPolicy[] from FrameworkInstanceWithControls
return isControlCompliant(control.policies, controlTasks);
}).length;

const compliance =
totalControls > 0 ? Math.round((compliantControls / totalControls) * 100) : 0;
const { complianceScore } = computeFrameworkStats(frameworkInstance, tasks);

return {
frameworkInstance,
complianceScore: compliance,
complianceScore: complianceScore,
};
});

Expand Down
46 changes: 46 additions & 0 deletions apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts
Original file line number Diff line number Diff line change
@@ -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<string, { id: string; status: string }>();
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,
};
}
7 changes: 7 additions & 0 deletions apps/app/src/app/(app)/[orgId]/frameworks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { redirect } from 'next/navigation';
import { cache } from 'react';
import { FrameworksOverview } from './components/FrameworksOverview';
import { getAllFrameworkInstancesWithControls } from './data/getAllFrameworkInstancesWithControls';
import { getFrameworkWithComplianceScores } from './data/getFrameworkWithComplianceScores';

export async function generateMetadata() {
return {
Expand Down Expand Up @@ -39,6 +40,11 @@ export default async function DashboardPage() {
organizationId,
});

const frameworksWithCompliance = await getFrameworkWithComplianceScores({
frameworksWithControls,
tasks,
});

const allFrameworks = await db.frameworkEditorFramework.findMany({
where: {
visible: true,
Expand All @@ -52,6 +58,7 @@ export default async function DashboardPage() {
frameworksWithControls={frameworksWithControls}
tasks={tasks}
allFrameworks={allFrameworks}
frameworksWithCompliance={frameworksWithCompliance}
/>
</PageWithBreadcrumb>
);
Expand Down
Loading
Loading