diff --git a/apps/app/languine.lock b/apps/app/languine.lock index 3676a59d21..71a13f3fcd 100644 --- a/apps/app/languine.lock +++ b/apps/app/languine.lock @@ -401,9 +401,9 @@ files: frameworks.controls.table.control: a1595abbb4c3a326636dd178757cd6c1 frameworks.controls.table.artifacts: 08004d9d4c599f44814a861568d200cb frameworks.controls.table.actions: 06df33001c1d7187fdd81ea1f5b277aa + frameworks.controls.statuses.completed: 07ca5050e697392c9ed47e6453f1453f + frameworks.controls.statuses.in_progress: c9413dd7682a45da9ecc9efa8cea47d7 frameworks.controls.statuses.not_started: 10db56f8b10dc989c43fbe33202e5339 - frameworks.controls.statuses.compliant: 952358fc53c0167a1ea7354f338ed5af - frameworks.controls.statuses.non_compliant: a0ecb19f7c90c0d5c1e919aec4d33aac vendor.title: 2938c7f7e560ed972f8a4f68e80ff834 vendor.register_title: 97f6291ea1000272b34fdfaeb3a9e2d2 vendor.dashboard.title: 2938c7f7e560ed972f8a4f68e80ff834 diff --git a/apps/app/package.json b/apps/app/package.json index 69dff64985..09d8446105 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -16,6 +16,7 @@ "@bubba/notifications": "workspace:*", "@date-fns/tz": "^1.2.0", "@hookform/resolvers": "^3.10.0", + "@nangohq/frontend": "^0.53.1", "@novu/headless": "^2.6.5", "@novu/react": "^2.6.5", "@prisma/instrumentation": "^6.3.1", @@ -45,6 +46,7 @@ "next-intl": "^3.26.3", "next-safe-action": "^7.10.3", "next-themes": "^0.4.4", + "novel": "^1.0.2", "novu": "^2.6.5", "nuqs": "^2.3.2", "playwright-core": "^1.50.1", diff --git a/apps/app/src/actions/framework/select-frameworks-action.ts b/apps/app/src/actions/framework/select-frameworks-action.ts index 7939a7d4a7..4b51886b63 100644 --- a/apps/app/src/actions/framework/select-frameworks-action.ts +++ b/apps/app/src/actions/framework/select-frameworks-action.ts @@ -34,7 +34,7 @@ export const selectFrameworksAction = authActionClient await createOrganizationCategories(user as User, frameworkIds); // Then create frameworks and controls - await Promise.all( + const organizationFrameworks = await Promise.all( frameworkIds.map((frameworkId) => createOrganizationFramework(user as User, frameworkId) ) @@ -43,6 +43,12 @@ export const selectFrameworksAction = authActionClient // Finally create policies await createOrganizationPolicy(user as User, frameworkIds); + // Finally create control requirements + await createOrganizationControlRequirements( + user as User, + organizationFrameworks.map((framework) => framework.id) + ); + return { data: true, }; @@ -105,6 +111,8 @@ const createOrganizationFramework = async (user: User, frameworkId: string) => { })), }); } + + return organizationFramework; }; const createOrganizationPolicy = async (user: User, frameworkIds: string[]) => { @@ -171,3 +179,68 @@ const createOrganizationCategories = async ( return organizationCategories; }; + +const createOrganizationControlRequirements = async ( + user: User, + organizationFrameworkIds: string[] +) => { + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + const controls = await db.organizationControl.findMany({ + where: { + organizationId: user.organizationId!, + organizationFrameworkId: { + in: organizationFrameworkIds, + }, + }, + include: { + control: true, + }, + }); + + // Create control requirements for each control + const controlRequirements = await db.controlRequirement.findMany({ + where: { + controlId: { in: controls.map((control) => control.controlId) }, + }, + include: { + policy: true, // Include the policy to get its ID + }, + }); + + // Get all organization policies for this organization + const organizationPolicies = await db.organizationPolicy.findMany({ + where: { + organizationId: user.organizationId, + }, + }); + + for (const control of controls) { + const requirements = controlRequirements.filter( + (req) => req.controlId === control.controlId + ); + + await db.organizationControlRequirement.createMany({ + data: requirements.map((requirement) => { + // Find the corresponding organization policy if this is a policy requirement + const policyId = + requirement.type === "policy" ? requirement.policy?.id : null; + const organizationPolicy = policyId + ? organizationPolicies.find((op) => op.policyId === policyId) + : null; + + return { + organizationControlId: control.id, + controlRequirementId: requirement.id, + type: requirement.type, + description: requirement.description, + organizationPolicyId: organizationPolicy?.id || null, + }; + }), + }); + } + + return controlRequirements; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts index 9dd4a2c9a0..c7ff34ad39 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControl.ts @@ -1,7 +1,7 @@ "use server"; -import { db, OrganizationControl, Control } from "@bubba/db"; import { authActionClient } from "@/actions/safe-action"; +import { db, type Control, type OrganizationControl } from "@bubba/db"; import { z } from "zod"; export interface OrganizationControlResponse { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlProgress.ts new file mode 100644 index 0000000000..e694f8ee79 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlProgress.ts @@ -0,0 +1,106 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export interface ControlProgressResponse { + total: number; + completed: number; + progress: number; + byType: { + [key: string]: { + total: number; + completed: number; + }; + }; +} + +export const getOrganizationControlProgress = authActionClient + .schema(z.object({ controlId: z.string() })) + .metadata({ + name: "getOrganizationControlProgress", + track: { + event: "get-organization-control-progress", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { controlId } = parsedInput; + + if (!user.organizationId) { + return { + error: "Not authorized - no organization found", + }; + } + + try { + const requirements = await db.organizationControlRequirement.findMany({ + where: { + organizationControlId: controlId, + }, + include: { + organizationPolicy: true, + }, + }); + + const progress: ControlProgressResponse = { + total: requirements.length, + completed: 0, + progress: 0, + byType: {}, + }; + + for (const requirement of requirements) { + // Initialize type counters if not exists + if (!progress.byType[requirement.type]) { + progress.byType[requirement.type] = { + total: 0, + completed: 0, + }; + } + + progress.byType[requirement.type].total++; + + // Check completion based on requirement type + let isCompleted = false; + switch (requirement.type) { + case "policy": + isCompleted = + requirement.organizationPolicy?.status === "published"; + break; + case "file": + isCompleted = !!requirement.fileUrl; + break; + case "evidence": + isCompleted = !!requirement.content; + break; + default: + isCompleted = requirement.published; + } + + if (isCompleted) { + progress.completed++; + progress.byType[requirement.type].completed++; + } + } + + // Calculate overall progress percentage + progress.progress = + progress.total > 0 + ? Math.round((progress.completed / progress.total) * 100) + : 0; + + return { + data: { + progress, + }, + }; + } catch (error) { + console.error("Error fetching control progress:", error); + return { + error: "Failed to fetch control progress", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlRequirements.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlRequirements.ts new file mode 100644 index 0000000000..fb3a3d24a2 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Actions/getOrganizationControlRequirements.ts @@ -0,0 +1,63 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import type { + ControlRequirement, + OrganizationControlRequirement, + Policy, +} from "@bubba/db"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const getOrganizationControlRequirements = authActionClient + .schema(z.object({ controlId: z.string() })) + .metadata({ + name: "getOrganizationControlRequirements", + track: { + event: "get-organization-control-requirements", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { controlId } = parsedInput; + + if (!user.organizationId) { + return { + error: "Not authorized - no organization found", + }; + } + + try { + const organizationControlRequirements = + await db.organizationControlRequirement.findMany({ + where: { + organizationControlId: controlId, + }, + include: { + organizationPolicy: { + include: { + policy: true, + }, + }, + }, + }); + + if (!organizationControlRequirements) { + return { + error: "Organization control requirements not found", + }; + } + + return { + data: { + organizationControlRequirements, + }, + }; + } catch (error) { + console.error("Error fetching organization control:", error); + return { + error: "Failed to fetch organization control", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx index c21286c5f4..3ceadba27d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/Components/SingleControl.tsx @@ -2,11 +2,17 @@ import { DisplayFrameworkStatus, - StatusType, + type StatusType, } from "@/components/frameworks/framework-status"; import { useOrganizationControl } from "../hooks/useOrganizationControl"; import { Card } from "@bubba/ui/card"; import { Label } from "@bubba/ui/label"; +import { Button } from "@bubba/ui/button"; +import { ArrowLeft, CheckCircle2, XCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useOrganizationControlRequirements } from "../hooks/useOrganizationControlRequirements"; +import Link from "next/link"; +import { useOrganizationControlProgress } from "../hooks/useOrganizationControlProgress"; interface SingleControlProps { controlId: string; @@ -14,15 +20,30 @@ interface SingleControlProps { export const SingleControl = ({ controlId }: SingleControlProps) => { const { data: control } = useOrganizationControl(controlId); - if (!control) return null; + const { data: requirements } = useOrganizationControlRequirements(controlId); + const { data: controlProgress } = useOrganizationControlProgress(controlId); + if (!control || !controlProgress) return null; + + const router = useRouter(); + + const progressStatus = + controlProgress?.progress?.completed > 0 + ? "in_progress" + : controlProgress?.progress?.completed === 0 + ? "not_started" + : "completed"; return ( -
+
+
+ +

{control?.control.name}

- +
@@ -42,6 +63,57 @@ export const SingleControl = ({ controlId }: SingleControlProps) => {
+ +
+

Requirements

+ + + + + + + + + + {requirements?.map((requirement) => { + const url = + requirement.type === "policy" + ? `/policies/${requirement.organizationPolicy?.policy?.id}` + : "_blank"; + + const isCompleted = + requirement.type === "policy" + ? requirement.organizationPolicy?.status === "published" + : false; + + return ( + + + + + + ); + })} + +
TypeDescriptionStatus
+ + {requirement.type} + + + + {requirement.description} + + + {isCompleted ? ( + + ) : ( + + )} +
+
); }; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlProgress.ts new file mode 100644 index 0000000000..0b28787f3f --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlProgress.ts @@ -0,0 +1,32 @@ +"use client"; + +import useSWR from "swr"; +import { getOrganizationControlProgress } from "../Actions/getOrganizationControlProgress"; + +async function fetchOrganizationControlProgress(controlId: string) { + const result = await getOrganizationControlProgress({ controlId }); + + if (!result || "error" in result || !result.data) { + throw new Error("Failed to fetch control progress"); + } + + return result.data.data; +} + +export function useOrganizationControlProgress(controlId: string) { + const { data, error, isLoading, mutate } = useSWR( + ["organization-control-progress", controlId], + () => fetchOrganizationControlProgress(controlId), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + return { + data, + isLoading, + error, + mutate, + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlRequirements.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlRequirements.ts new file mode 100644 index 0000000000..49180a184b --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControlRequirements.ts @@ -0,0 +1,37 @@ +"use client"; + +import useSWR from "swr"; +import { getOrganizationControlRequirements } from "../Actions/getOrganizationControlRequirements"; + +async function fetchOrganizationControlRequirements(controlId: string) { + const result = await getOrganizationControlRequirements({ controlId }); + + if (!result) { + throw new Error("Failed to fetch control"); + } + + const data = result.data?.data; + if (!data) { + throw new Error("Invalid response from server"); + } + + return data.organizationControlRequirements; +} + +export function useOrganizationControlRequirements(controlId: string) { + const { data, error, isLoading, mutate } = useSWR( + ["organization-control-requirements", controlId], + () => fetchOrganizationControlRequirements(controlId), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + return { + data, + isLoading, + error, + mutate, + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts index b9b1765a2e..a71f50d4f4 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationCategories.ts @@ -8,12 +8,17 @@ import type { OrganizationFramework, OrganizationCategory, Control, + OrganizationControlRequirement, + OrganizationPolicy, } from "@bubba/db"; import { z } from "zod"; export type OrganizationCategoryWithControls = OrganizationCategory & { organizationControl: (OrganizationControl & { control: Control; + OrganizationControlRequirement: (OrganizationControlRequirement & { + organizationPolicy: Pick | null; + })[]; })[]; }; @@ -50,6 +55,15 @@ export const getOrganizationCategories = authActionClient organizationControl: { include: { control: true, + OrganizationControlRequirement: { + include: { + organizationPolicy: { + select: { + status: true, + }, + }, + }, + }, }, }, }, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationControlsProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationControlsProgress.ts new file mode 100644 index 0000000000..2a3daea462 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Actions/getOrganizationControlsProgress.ts @@ -0,0 +1,122 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export interface ControlProgress { + controlId: string; + total: number; + completed: number; + progress: number; + byType: { + [key: string]: { + total: number; + completed: number; + }; + }; +} + +export const getOrganizationControlsProgress = authActionClient + .schema(z.object({ controlIds: z.array(z.string()) })) + .metadata({ + name: "getOrganizationControlsProgress", + track: { + event: "get-organization-controls-progress", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { controlIds } = parsedInput; + + if (!user.organizationId) { + return { + error: "Not authorized - no organization found", + }; + } + + try { + const requirements = await db.organizationControlRequirement.findMany({ + where: { + organizationControlId: { + in: controlIds, + }, + }, + include: { + organizationPolicy: true, + }, + }); + + const progressByControl = new Map(); + + // Initialize progress for all controls + for (const controlId of controlIds) { + progressByControl.set(controlId, { + controlId, + total: 0, + completed: 0, + progress: 0, + byType: {}, + }); + } + + // Calculate progress for each control + for (const requirement of requirements) { + const controlId = requirement.organizationControlId; + const progress = progressByControl.get(controlId)!; + + // Initialize type counters if not exists + if (!progress.byType[requirement.type]) { + progress.byType[requirement.type] = { + total: 0, + completed: 0, + }; + } + + progress.total++; + progress.byType[requirement.type].total++; + + // Check completion based on requirement type + let isCompleted = false; + switch (requirement.type) { + case "policy": + isCompleted = + requirement.organizationPolicy?.status === "published"; + break; + case "file": + isCompleted = !!requirement.fileUrl; + break; + case "evidence": + isCompleted = !!requirement.content; + break; + default: + isCompleted = requirement.published; + } + + if (isCompleted) { + progress.completed++; + progress.byType[requirement.type].completed++; + } + } + + // Calculate progress percentage for each control + for (const progress of progressByControl.values()) { + progress.progress = + progress.total > 0 + ? Math.round((progress.completed / progress.total) * 100) + : 0; + } + + return { + data: { + progress: Array.from(progressByControl.values()), + }, + }; + } catch (error) { + console.error("Error fetching controls progress:", error); + return { + error: "Failed to fetch controls progress", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx index 139b897688..fccd25bde7 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/Components/FrameworkControls.tsx @@ -1,6 +1,5 @@ "use client"; -import type { TransformedCategory } from "@/types/framework"; import { Card, CardContent, @@ -10,8 +9,9 @@ import { } from "@bubba/ui/card"; import { DataTable } from "@/components/tables/frameworks/data-table"; -import { useOrganizationFramework } from "@/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationFramework"; import { useOrganizationCategories } from "@/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories"; +import { useMemo } from "react"; +import type { OrganizationControlType } from "@/components/tables/frameworks/columns"; interface FrameworkControlsProps { frameworkId: string; @@ -21,6 +21,23 @@ export function FrameworkControls({ frameworkId }: FrameworkControlsProps) { const { data: organizationCategories } = useOrganizationCategories(frameworkId); + const allControls = useMemo(() => { + if (!organizationCategories) return []; + + return organizationCategories.flatMap((category) => + category.organizationControl.map((control) => ({ + code: control.control.code, + description: control.control.description, + name: control.control.name, + status: control.status, + id: control.id, + frameworkId, + category: category.name, + requirements: control.OrganizationControlRequirement, + })) + ); + }, [organizationCategories, frameworkId]) as OrganizationControlType[]; + if (!organizationCategories) { return null; } @@ -32,21 +49,7 @@ export function FrameworkControls({ frameworkId }: FrameworkControlsProps) { Review and manage compliance controls - {organizationCategories.map((category) => ( -
-

{category.name}

- ({ - code: control.control.code, - description: control.control.description, - name: control.control.name, - status: control.status, - id: control.id, - frameworkId, - }))} - /> -
- ))} +
); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts index 4329e213c9..88e554d282 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/frameworks/[frameworkId]/hooks/useOrganizationCategories.ts @@ -1,18 +1,11 @@ "use client"; import useSWR from "swr"; -import { - getOrganizationCategories, - OrganizationCategoryWithControls, -} from "../Actions/getOrganizationCategories"; +import { getOrganizationCategories } from "../Actions/getOrganizationCategories"; -async function fetchOrganizationCategories( - frameworkId: string -): Promise { +async function fetchOrganizationCategories(frameworkId: string) { const result = await getOrganizationCategories({ frameworkId }); - console.log({ result }); - if (!result) { throw new Error("Failed to fetch frameworks"); } @@ -26,9 +19,7 @@ async function fetchOrganizationCategories( } export function useOrganizationCategories(frameworkId: string) { - const { data, error, isLoading, mutate } = useSWR< - OrganizationCategoryWithControls[] - >( + const { data, error, isLoading, mutate } = useSWR( ["organization-categories", frameworkId], () => fetchOrganizationCategories(frameworkId), { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts index ead1296cdb..05a4362bfb 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts @@ -34,10 +34,27 @@ export const publishPolicy = authActionClient }; } + const policy = await db.organizationPolicy.findFirst({ + where: { + policyId: id, + organizationId: user.organizationId!, + }, + select: { + id: true, + }, + }); + + if (!policy?.id) { + return { + success: false, + error: "Policy not found", + }; + } + try { - const policy = await db.organizationPolicy.update({ + const organizationPolicy = await db.organizationPolicy.update({ where: { - id, + id: policy.id, organizationId: user.organizationId!, }, data: { @@ -48,7 +65,7 @@ export const publishPolicy = authActionClient return { success: true, - data: policy, + data: organizationPolicy, }; } catch (error) { return { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx index 72b3dd021f..65812cfdbf 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx @@ -22,11 +22,13 @@ export default function PolicyPage() { onError: () => { toast.error("Failed to publish policy, please try again."); }, - }, + } ); const { data: policy } = usePolicy({ policyId: id as string }); + console.log({ policy }); + if (!policy) return null; const content = policy.content as JSONContent; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts index 4c678e084b..8f5ef40dc5 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts @@ -38,7 +38,7 @@ export const getPolicy = authActionClient const policy = await db.organizationPolicy.findFirst({ where: { organizationId: user.organizationId!, - id: policyId, + policyId, }, include: { policy: true, diff --git a/apps/app/src/components/frameworks/framework-status.tsx b/apps/app/src/components/frameworks/framework-status.tsx index 5a4172463a..78dd9569c2 100644 --- a/apps/app/src/components/frameworks/framework-status.tsx +++ b/apps/app/src/components/frameworks/framework-status.tsx @@ -2,8 +2,8 @@ import { useI18n } from "@/locales/client"; import { cn } from "@bubba/ui/cn"; export const STATUS_TYPES = [ - "compliant", - "non_compliant", + "completed", + "in_progress", "not_started", ] as const; @@ -13,8 +13,8 @@ export type StatusType = Exclude< >; const STATUS_COLORS: Record = { - compliant: "#22c55e", - non_compliant: "#f43f5e", + completed: "#22c55e", + in_progress: "#eab308", not_started: "#f43f5e", } as const; diff --git a/apps/app/src/components/tables/frameworks/columns.tsx b/apps/app/src/components/tables/frameworks/columns.tsx index fdb0a11127..0cf713bf1a 100644 --- a/apps/app/src/components/tables/frameworks/columns.tsx +++ b/apps/app/src/components/tables/frameworks/columns.tsx @@ -5,19 +5,59 @@ import { type StatusType, } from "@/components/frameworks/framework-status"; import { useI18n } from "@/locales/client"; -import type { ArtifactType, ComplianceStatus } from "@bubba/db"; +import type { + ArtifactType, + ComplianceStatus, + OrganizationControlRequirement, +} from "@bubba/db"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bubba/ui/tooltip"; export type OrganizationControlType = { - id: string; - name: string; code: string; description: string | null; + name: string; status: ComplianceStatus; + id: string; frameworkId: string; + category: string; + requirements: (OrganizationControlRequirement & { + organizationPolicy: { + status: "draft" | "published" | "archived"; + } | null; + })[]; }; +function getControlStatus( + requirements: OrganizationControlType["requirements"] +): StatusType { + if (!requirements || requirements.length === 0) return "not_started"; + + const totalRequirements = requirements.length; + const completedRequirements = requirements.filter((req) => { + switch (req.type) { + case "policy": + return req.organizationPolicy?.status === "published"; + case "file": + return !!req.fileUrl; + case "evidence": + return !!req.content; + default: + return req.published; + } + }).length; + + if (completedRequirements === 0) return "not_started"; + if (completedRequirements === totalRequirements) return "completed"; + return "in_progress"; +} + export function columns(): ColumnDef[] { const t = useI18n(); @@ -42,18 +82,63 @@ export function columns(): ColumnDef[] { ); }, }, + { + id: "category", + accessorKey: "category", + header: t("risk.vendor.table.category"), + cell: ({ row }) => ( +
+ {row.original.category} +
+ ), + }, { id: "status", accessorKey: "status", header: t("frameworks.controls.table.status"), cell: ({ row }) => { - const status = row.original.status; + const requirements = row.original.requirements; + const status = getControlStatus(requirements); + + const totalRequirements = requirements.length; + const completedRequirements = requirements.filter((req) => { + switch (req.type) { + case "policy": + return req.organizationPolicy?.status === "published"; + case "file": + return !!req.fileUrl; + case "evidence": + return !!req.content; + default: + return req.published; + } + }).length; + return ( -
- -
+ + + +
+ +
+
+ +
+

+ Progress:{" "} + {Math.round( + (completedRequirements / totalRequirements) * 100 + ) || 0} + % +

+

+ Completed: {completedRequirements}/{totalRequirements}{" "} + requirements +

+
+
+
+
); }, }, diff --git a/apps/app/src/components/tables/frameworks/data-table-header.tsx b/apps/app/src/components/tables/frameworks/data-table-header.tsx index 4861fbd7b0..c54fbf9292 100644 --- a/apps/app/src/components/tables/frameworks/data-table-header.tsx +++ b/apps/app/src/components/tables/frameworks/data-table-header.tsx @@ -45,7 +45,7 @@ export function DataTableHeader({ table, loading }: Props) { router.replace(`${pathname}?${params.toString()}`); }, - [searchParams, router, pathname], + [searchParams, router, pathname] ); const isVisible = (id: string) => @@ -72,6 +72,24 @@ export function DataTableHeader({ table, loading }: Props) { )} + {isVisible("category") && ( + + + + )} + {isVisible("status") && (