diff --git a/apps/app/src/actions/framework/select-frameworks-action.ts b/apps/app/src/actions/framework/select-frameworks-action.ts deleted file mode 100644 index dfd02d3462..0000000000 --- a/apps/app/src/actions/framework/select-frameworks-action.ts +++ /dev/null @@ -1,314 +0,0 @@ -"use server"; - -import { db, RequirementType, type Policy, type User } from "@bubba/db"; -import { authActionClient } from "../safe-action"; -import { z } from "zod"; -import type { ActionData } from "../types"; -import type { InputJsonValue } from "@prisma/client/runtime/library"; - -const selectFrameworksSchema = z.object({ - frameworkIds: z.array(z.string()), -}); - -export const selectFrameworksAction = authActionClient - .schema(selectFrameworksSchema) - .metadata({ - name: "select-frameworks", - track: { - event: "select-frameworks", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }): Promise> => { - const { frameworkIds } = parsedInput; - const { user } = ctx; - - if (!user.organizationId) { - return { - error: "Not authorized - no organization found", - }; - } - - try { - // Create categories - await createOrganizationCategories(user as User, frameworkIds); - - // Create frameworks and controls - const organizationFrameworks = await Promise.all( - frameworkIds.map((frameworkId) => - createOrganizationFramework(user as User, frameworkId), - ), - ); - - // Create policies - await createOrganizationPolicy(user as User, frameworkIds); - - // Create organization evidence first - await createOrganizationEvidence(user as User, frameworkIds); - - // Create control requirements after evidence is created - await createOrganizationControlRequirements( - user as User, - organizationFrameworks.map((framework) => framework.id), - ); - - return { - data: true, - }; - } catch (error) { - console.error("Error selecting frameworks:", error); - return { - error: "Failed to select frameworks", - }; - } - }); - -const createOrganizationFramework = async (user: User, frameworkId: string) => { - if (!user.organizationId) { - throw new Error("Not authorized - no organization found"); - } - - // Connect the framework to the organization. - const organizationFramework = await db.organizationFramework.create({ - data: { - organizationId: user.organizationId, - frameworkId, - status: "not_started", - }, - select: { - id: true, - }, - }); - - // Get the framework categories and their corresponding organization categories - const frameworkCategories = await db.frameworkCategory.findMany({ - where: { frameworkId }, - include: { - controls: true, - }, - }); - - // Get the organization categories that were just created - const organizationCategories = await db.organizationCategory.findMany({ - where: { - organizationId: user.organizationId, - frameworkId, - }, - }); - - // Create controls for each category - for (const frameworkCategory of frameworkCategories) { - const organizationCategory = organizationCategories.find( - (oc) => oc.name === frameworkCategory.name, - ); - - if (!organizationCategory) continue; - - await db.organizationControl.createMany({ - data: frameworkCategory.controls.map((control) => ({ - organizationFrameworkId: organizationFramework.id, - controlId: control.id, - organizationId: user.organizationId!, - status: "not_started", - organizationCategoryId: organizationCategory.id, - })), - }); - } - - return organizationFramework; -}; - -const createOrganizationPolicy = async (user: User, frameworkIds: string[]) => { - if (!user.organizationId) { - throw new Error("Not authorized - no organization found"); - } - - const policies = await db.policy.findMany(); - const policiesForFrameworks: Policy[] = []; - - for (const policy of policies) { - const usedBy = policy.usedBy; - if (!usedBy) { - continue; - } - - const usedByFrameworkIds = Object.keys(usedBy); - - if ( - usedByFrameworkIds.some((frameworkId) => - frameworkIds.includes(frameworkId), - ) - ) { - policiesForFrameworks.push(policy); - } - } - - const organizationPolicies = await db.organizationPolicy.createMany({ - data: policiesForFrameworks.map((policy) => ({ - organizationId: user.organizationId!, - policyId: policy.id, - status: "draft", - content: policy.content as InputJsonValue[], - frequency: policy.frequency, - })), - }); - - return organizationPolicies; -}; - -const createOrganizationCategories = async ( - user: User, - frameworkIds: string[], -) => { - if (!user.organizationId) { - throw new Error("Not authorized - no organization found"); - } - - // For each frameworkCategory we need to get the controls. - const frameworkCategories = await db.frameworkCategory.findMany({ - where: { - frameworkId: { in: frameworkIds }, - }, - }); - - // Create the organization categories. - const organizationCategories = await db.organizationCategory.createMany({ - data: frameworkCategories.map((category) => ({ - name: category.name, - description: category.description, - organizationId: user.organizationId!, - frameworkId: category.frameworkId, - })), - }); - - 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 - evidence: true, // Include the evidence to get its ID - }, - }); - - // Get all organization policies for this organization - const organizationPolicies = await db.organizationPolicy.findMany({ - where: { - organizationId: user.organizationId, - }, - }); - - // Get all organization evidences for this organization - const organizationEvidences = await db.organizationEvidence.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; - - const evidenceId = - requirement.type === "evidence" ? requirement.evidenceId : null; - - console.log({ - evidenceId, - }); - - const organizationEvidence = evidenceId - ? organizationEvidences.find((e) => e.evidenceId === evidenceId) - : null; - - console.log({ - organizationEvidence, - }); - - return { - organizationControlId: control.id, - controlRequirementId: requirement.id, - type: requirement.type, - description: requirement.description, - organizationPolicyId: organizationPolicy?.id || null, - organizationEvidenceId: organizationEvidence?.id || null, - }; - }), - }); - } - - return controlRequirements; -}; - -const createOrganizationEvidence = async ( - user: User, - frameworkIds: string[], -) => { - if (!user.organizationId) { - throw new Error("Not authorized - no organization found"); - } - - const evidence = await db.controlRequirement.findMany({ - where: { - type: RequirementType.evidence, - }, - include: { - control: { - include: { - frameworkCategory: { - include: { - framework: true, - }, - }, - }, - }, - }, - }); - - const organizationEvidence = await db.organizationEvidence.createMany({ - data: evidence.map((evidence) => ({ - organizationId: user.organizationId!, - evidenceId: evidence.id, - name: evidence.name, - description: evidence.description, - frequency: evidence.frequency, - frameworkId: evidence.control.frameworkCategory?.framework.id || "", - assigneeId: user.id, - })), - }); - - return organizationEvidence; -}; diff --git a/apps/app/src/actions/framework/get-framework-categories.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/getFrameworkCategoriesAction.ts similarity index 93% rename from apps/app/src/actions/framework/get-framework-categories.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/getFrameworkCategoriesAction.ts index 732aa4fc6d..dcb62c2eca 100644 --- a/apps/app/src/actions/framework/get-framework-categories.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/getFrameworkCategoriesAction.ts @@ -1,11 +1,9 @@ "use server"; -import type { - TransformedCategory, -} from "@/types/framework"; +import type { TransformedCategory } from "@/types/framework"; import { db } from "@bubba/db"; import { z } from "zod"; -import { authActionClient } from "../safe-action"; +import { authActionClient } from "@/actions/safe-action"; const getCategoriesSchema = z.object({ frameworkId: z.string(), @@ -61,7 +59,7 @@ export const getFrameworkCategoriesAction = authActionClient requiredArtifactTypes: control.requirements?.map((req) => req.type) || [], })), - }), + }) ); return { diff --git a/apps/app/src/actions/framework/get-frameworks-action.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/getFrameworksAction.ts similarity index 92% rename from apps/app/src/actions/framework/get-frameworks-action.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/getFrameworksAction.ts index 0418c97748..bc8cc95073 100644 --- a/apps/app/src/actions/framework/get-frameworks-action.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/getFrameworksAction.ts @@ -1,14 +1,14 @@ "use server"; import { db } from "@bubba/db"; -import { authActionClient } from "../safe-action"; +import { authActionClient } from "@/actions/safe-action"; import type { Framework, OrganizationControl, OrganizationFramework, } from "@bubba/db"; import { z } from "zod"; -import type { ActionData } from "../types"; +import type { ActionData } from "@/actions/types"; type FrameworkWithControls = OrganizationFramework & { organizationControl: OrganizationControl[]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/selectFrameworksAction.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/selectFrameworksAction.ts new file mode 100644 index 0000000000..910036a774 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/actions/selectFrameworksAction.ts @@ -0,0 +1,314 @@ +"use server"; + +import { db, RequirementType, type Policy, type User } from "@bubba/db"; +import { authActionClient } from "@/actions/safe-action"; +import { z } from "zod"; +import type { ActionData } from "@/actions/types"; +import type { InputJsonValue } from "@prisma/client/runtime/library"; + +const selectFrameworksSchema = z.object({ + frameworkIds: z.array(z.string()), +}); + +export const selectFrameworksAction = authActionClient + .schema(selectFrameworksSchema) + .metadata({ + name: "select-frameworks", + track: { + event: "select-frameworks", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }): Promise> => { + const { frameworkIds } = parsedInput; + const { user } = ctx; + + if (!user.organizationId) { + return { + error: "Not authorized - no organization found", + }; + } + + try { + // Create categories + await createOrganizationCategories(user as User, frameworkIds); + + // Create frameworks and controls + const organizationFrameworks = await Promise.all( + frameworkIds.map((frameworkId) => + createOrganizationFramework(user as User, frameworkId) + ) + ); + + // Create policies + await createOrganizationPolicy(user as User, frameworkIds); + + // Create organization evidence first + await createOrganizationEvidence(user as User, frameworkIds); + + // Create control requirements after evidence is created + await createOrganizationControlRequirements( + user as User, + organizationFrameworks.map((framework) => framework.id) + ); + + return { + data: true, + }; + } catch (error) { + console.error("Error selecting frameworks:", error); + return { + error: "Failed to select frameworks", + }; + } + }); + +const createOrganizationFramework = async (user: User, frameworkId: string) => { + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + // Connect the framework to the organization. + const organizationFramework = await db.organizationFramework.create({ + data: { + organizationId: user.organizationId, + frameworkId, + status: "not_started", + }, + select: { + id: true, + }, + }); + + // Get the framework categories and their corresponding organization categories + const frameworkCategories = await db.frameworkCategory.findMany({ + where: { frameworkId }, + include: { + controls: true, + }, + }); + + // Get the organization categories that were just created + const organizationCategories = await db.organizationCategory.findMany({ + where: { + organizationId: user.organizationId, + frameworkId, + }, + }); + + // Create controls for each category + for (const frameworkCategory of frameworkCategories) { + const organizationCategory = organizationCategories.find( + (oc) => oc.name === frameworkCategory.name + ); + + if (!organizationCategory) continue; + + await db.organizationControl.createMany({ + data: frameworkCategory.controls.map((control) => ({ + organizationFrameworkId: organizationFramework.id, + controlId: control.id, + organizationId: user.organizationId!, + status: "not_started", + organizationCategoryId: organizationCategory.id, + })), + }); + } + + return organizationFramework; +}; + +const createOrganizationPolicy = async (user: User, frameworkIds: string[]) => { + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + const policies = await db.policy.findMany(); + const policiesForFrameworks: Policy[] = []; + + for (const policy of policies) { + const usedBy = policy.usedBy; + if (!usedBy) { + continue; + } + + const usedByFrameworkIds = Object.keys(usedBy); + + if ( + usedByFrameworkIds.some((frameworkId) => + frameworkIds.includes(frameworkId) + ) + ) { + policiesForFrameworks.push(policy); + } + } + + const organizationPolicies = await db.organizationPolicy.createMany({ + data: policiesForFrameworks.map((policy) => ({ + organizationId: user.organizationId!, + policyId: policy.id, + status: "draft", + content: policy.content as InputJsonValue[], + frequency: policy.frequency, + })), + }); + + return organizationPolicies; +}; + +const createOrganizationCategories = async ( + user: User, + frameworkIds: string[] +) => { + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + // For each frameworkCategory we need to get the controls. + const frameworkCategories = await db.frameworkCategory.findMany({ + where: { + frameworkId: { in: frameworkIds }, + }, + }); + + // Create the organization categories. + const organizationCategories = await db.organizationCategory.createMany({ + data: frameworkCategories.map((category) => ({ + name: category.name, + description: category.description, + organizationId: user.organizationId!, + frameworkId: category.frameworkId, + })), + }); + + 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 + evidence: true, // Include the evidence to get its ID + }, + }); + + // Get all organization policies for this organization + const organizationPolicies = await db.organizationPolicy.findMany({ + where: { + organizationId: user.organizationId, + }, + }); + + // Get all organization evidences for this organization + const organizationEvidences = await db.organizationEvidence.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; + + const evidenceId = + requirement.type === "evidence" ? requirement.evidenceId : null; + + console.log({ + evidenceId, + }); + + const organizationEvidence = evidenceId + ? organizationEvidences.find((e) => e.evidenceId === evidenceId) + : null; + + console.log({ + organizationEvidence, + }); + + return { + organizationControlId: control.id, + controlRequirementId: requirement.id, + type: requirement.type, + description: requirement.description, + organizationPolicyId: organizationPolicy?.id || null, + organizationEvidenceId: organizationEvidence?.id || null, + }; + }), + }); + } + + return controlRequirements; +}; + +const createOrganizationEvidence = async ( + user: User, + frameworkIds: string[] +) => { + if (!user.organizationId) { + throw new Error("Not authorized - no organization found"); + } + + const evidence = await db.controlRequirement.findMany({ + where: { + type: RequirementType.evidence, + }, + include: { + control: { + include: { + frameworkCategory: { + include: { + framework: true, + }, + }, + }, + }, + }, + }); + + const organizationEvidence = await db.organizationEvidence.createMany({ + data: evidence.map((evidence) => ({ + organizationId: user.organizationId!, + evidenceId: evidence.id, + name: evidence.name, + description: evidence.description, + frequency: evidence.frequency, + frameworkId: evidence.control.frameworkCategory?.framework.id || "", + assigneeId: user.id, + })), + }); + + return organizationEvidence; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksGrid.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksGrid.tsx index 04992349f3..dbac004cd5 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksGrid.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksGrid.tsx @@ -3,119 +3,127 @@ import { useI18n } from "@/locales/client"; import type { Framework } from "@bubba/db"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, } from "@bubba/ui/card"; import { Button } from "@bubba/ui/button"; import { Checkbox } from "@bubba/ui/checkbox"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import { cn } from "@bubba/ui/cn"; interface FrameworkGridProps { - frameworks: Framework[]; - onSubmit: (selectedFrameworks: string[]) => Promise; + frameworks: Framework[]; + onSubmit: (selectedFrameworks: string[]) => Promise; } export function FrameworkGrid({ frameworks, onSubmit }: FrameworkGridProps) { - const t = useI18n(); - const router = useRouter(); - const [selectedFrameworks, setSelectedFrameworks] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isSelecting, setIsSelecting] = useState(false); + const t = useI18n(); + const [selectedFrameworks, setSelectedFrameworks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); - const handleSubmit = async () => { - if (selectedFrameworks.length === 0) return; + /** + * Toggles the selection state of a framework. + * If the framework is already selected, it removes it from the selection. + * If the framework is not selected, it adds it to the selection. + * + * @param frameworkId - The ID of the framework to toggle + */ + const handleFrameworkToggle = (frameworkId: string) => { + setSelectedFrameworks((prev) => + prev.includes(frameworkId) + ? prev.filter((id) => id !== frameworkId) + : [...prev, frameworkId], + ); + }; - setIsLoading(true); - try { - await onSubmit(selectedFrameworks); - router.refresh(); - } catch (error) { - console.error("Error selecting frameworks:", error); - } finally { - setIsLoading(false); - } - }; + const handleSubmit = async () => { + if (selectedFrameworks.length === 0) return; - if (!frameworks.length && !isSelecting) { - return ( -
-

- {t("frameworks.overview.grid.welcome.title")} -

-

- {t("frameworks.overview.grid.welcome.description")} -

- -
- ); - } + setIsLoading(true); + try { + await onSubmit(selectedFrameworks); + } catch (error) { + console.error("Error selecting frameworks:", error); + } finally { + setIsLoading(false); + } + }; - return ( - - - {t("frameworks.overview.grid.title")} - - -
- {frameworks.map((framework) => ( -
{ - setSelectedFrameworks((prev) => - prev.includes(framework.id) - ? prev.filter((id) => id !== framework.id) - : [...prev, framework.id], - ); - }} - > -
-
-

{framework.name}

-

- {framework.description} -

-

- {`${t("frameworks.overview.grid.version")}: ${framework.version}`} -

-
- -
-
- ))} -
-
- - - - -
- ); + if (!frameworks.length && !isSelecting) { + return ( +
+

+ {t("frameworks.overview.grid.welcome.title")} +

+

+ {t("frameworks.overview.grid.welcome.description")} +

+ +
+ ); + } + + return ( + + + {t("frameworks.overview.grid.title")} + + +
+ {frameworks.map((framework) => ( +
handleFrameworkToggle(framework.id)} + > +
+
+

{framework.name}

+

+ {framework.description} +

+

+ {`${t("frameworks.overview.grid.version")}: ${framework.version}`} +

+
+ e.stopPropagation()} + onCheckedChange={() => handleFrameworkToggle(framework.id)} + /> +
+
+ ))} +
+
+ + + + +
+ ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksOverview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksOverview.tsx index 2415f7ace0..c4596a2ed0 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksOverview.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/FrameworksOverview.tsx @@ -1,13 +1,14 @@ "use client"; -import { RequirementStatus } from "@/components/charts/requirement-status"; import { useI18n } from "@/locales/client"; import { Skeleton } from "@bubba/ui/skeleton"; import { useFrameworks } from "../hooks/useFrameworks"; import { FrameworkProgress } from "./FrameworkProgress"; import { FrameworkGrid } from "./FrameworksGrid"; +import { RequirementStatus } from "./RequirementStatusChart"; export const FrameworksOverview = () => { + const t = useI18n(); const { frameworks, availableFrameworks, @@ -16,8 +17,6 @@ export const FrameworksOverview = () => { selectFrameworks, } = useFrameworks(); - const t = useI18n(); - if (error) { return (
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/RequirementStatusChart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/RequirementStatusChart.tsx new file mode 100644 index 0000000000..a6507b168f --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/components/RequirementStatusChart.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useI18n } from "@/locales/client"; +import type { Framework, OrganizationFramework } from "@bubba/db"; +import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; +import { Progress } from "@bubba/ui/progress"; +import Link from "next/link"; +import { useMemo } from "react"; +import type { OrganizationControlType } from "../overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableColumns"; +import { useOrganizationCategories } from "../overview/frameworks/[frameworkId]/hooks/useOrganizationCategories"; + +interface Props { + frameworks: (OrganizationFramework & { + framework: Framework; + })[]; +} + +// Helper function to check if a control is compliant based on its requirements +const isControlCompliant = (control: OrganizationControlType) => { + // First, check if the control has the direct status of "compliant" + if (control.status === "compliant") { + return true; + } + + // Then check the requirements if they exist + const requirements = control.requirements; + + if (!requirements || requirements.length === 0) { + return false; + } + + const totalRequirements = requirements.length; + const completedRequirements = requirements.filter((req) => { + let isCompleted = false; + + switch (req.type) { + case "policy": + isCompleted = req.organizationPolicy?.status === "published"; + break; + case "file": + isCompleted = !!req.fileUrl; + break; + case "evidence": + isCompleted = req.organizationEvidence?.published === true; + break; + default: + isCompleted = req.published || false; + } + + return isCompleted; + }).length; + + return completedRequirements === totalRequirements; +}; + +// Individual FrameworkCard component +function FrameworkCard({ + framework, +}: { framework: OrganizationFramework & { framework: Framework } }) { + const { data: organizationCategories, isLoading } = useOrganizationCategories( + framework.framework.id, + ); + + // Transform the organizationCategories into controls + const controls = 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: framework.framework.id, + category: category.name, + requirements: control.OrganizationControlRequirement, + })), + ); + }, [ + organizationCategories, + framework.framework.id, + ]) as OrganizationControlType[]; + + // Calculate framework compliance based on controls + const compliance = useMemo(() => { + if (isLoading || controls.length === 0) return 0; + + const totalControls = controls.length; + const compliantControls = controls.filter(isControlCompliant).length; + + return totalControls > 0 + ? Math.round((compliantControls / totalControls) * 100) + : 0; + }, [controls, isLoading]); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + return ( + +
+
+ {framework.framework.name.substring(0, 2).toUpperCase()} +
+
+
+
+

{framework.framework.name}

+ + {compliance}% Compliant + +
+ +
+ + ); +} + +// Main component +export function RequirementStatus({ frameworks }: Props) { + const t = useI18n(); + const isLoading = !frameworks; + + return ( + + + {t("frameworks.title")} + + View All + + + + {isLoading ? ( +
+
+

+ Loading compliance data... +

+
+ ) : frameworks.length === 0 ? ( +
+

+ {t("frameworks.overview.empty.description")} +

+
+ ) : ( +
+ {/* Framework List */} +
+ {frameworks.map((framework) => ( + + ))} +
+
+ )} + + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/hooks/useFrameworks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/hooks/useFrameworks.ts index 21eebeb609..807531b1d2 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/hooks/useFrameworks.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/hooks/useFrameworks.ts @@ -1,19 +1,19 @@ "use client"; -import type { FrameworksResponse } from "@/actions/framework/get-frameworks-action"; -import { getFrameworksAction } from "@/actions/framework/get-frameworks-action"; -import { selectFrameworksAction } from "@/actions/framework/select-frameworks-action"; import { useCallback, useState } from "react"; import useSWR from "swr"; +import { getFrameworksAction } from "../actions/getFrameworksAction"; +import { selectFrameworksAction } from "../actions/selectFrameworksAction"; -async function fetchFrameworks(): Promise { +async function fetchFrameworks() { const result = await getFrameworksAction(); if (!result) { throw new Error("Failed to fetch frameworks"); } - const data = result.data?.data as FrameworksResponse | undefined; + const data = result.data?.data; + if (!data) { throw new Error("Invalid response from server"); } @@ -27,10 +27,7 @@ export function useFrameworks() { error, isLoading, mutate: revalidateFrameworks, - } = useSWR("frameworks", () => fetchFrameworks(), { - revalidateOnFocus: false, - revalidateOnReconnect: false, - }); + } = useSWR("frameworks", () => fetchFrameworks()); const [isMutating, setIsMutating] = useState(false); @@ -52,7 +49,7 @@ export function useFrameworks() { setIsMutating(false); } }, - [revalidateFrameworks], + [revalidateFrameworks] ); return { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx index 4dcc53f74b..be3cb41a5f 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/layout.tsx @@ -1,4 +1,6 @@ +import { auth } from "@/auth"; import { getI18n } from "@/locales/server"; +import { redirect } from "next/navigation"; import { SecondaryMenu } from "@bubba/ui/secondary-menu"; export default async function Layout({ @@ -7,6 +9,11 @@ export default async function Layout({ children: React.ReactNode; }) { const t = await getI18n(); + const session = await auth(); + + if (!session?.user?.organizationId) { + redirect("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/login"); + } return (
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationCategories.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationCategories.ts index bc1c442c39..73a548218b 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationCategories.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationCategories.ts @@ -3,9 +3,7 @@ import { db } from "@bubba/db"; import { authActionClient } from "@/actions/safe-action"; import type { - Framework, OrganizationControl, - OrganizationFramework, OrganizationCategory, Control, OrganizationControlRequirement, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationControlsProgress.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationControlsProgress.ts index 2a3daea462..76e5c79b96 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationControlsProgress.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationControlsProgress.ts @@ -4,6 +4,10 @@ import { authActionClient } from "@/actions/safe-action"; import { db } from "@bubba/db"; import { z } from "zod"; +const schema = z.object({ + controlIds: z.array(z.string()), +}); + export interface ControlProgress { controlId: string; total: number; @@ -18,7 +22,7 @@ export interface ControlProgress { } export const getOrganizationControlsProgress = authActionClient - .schema(z.object({ controlIds: z.array(z.string()) })) + .schema(schema) .metadata({ name: "getOrganizationControlsProgress", track: { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationFramework.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationFramework.ts index f167aba545..51fdca2a4d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationFramework.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/actions/getOrganizationFramework.ts @@ -9,6 +9,10 @@ import type { } from "@bubba/db"; import { z } from "zod"; +const schema = z.object({ + frameworkId: z.string(), +}); + export type FrameworkWithControls = OrganizationFramework & { organizationControl: OrganizationControl[]; framework: Framework; @@ -20,7 +24,7 @@ export interface FrameworksResponse { } export const getOrganizationFramework = authActionClient - .schema(z.object({ frameworkId: z.string() })) + .schema(schema) .metadata({ name: "getOrganizationFramework", track: { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkControls.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkControls.tsx index 1c3929e95c..308d726b58 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkControls.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkControls.tsx @@ -1,9 +1,9 @@ "use client"; -import { DataTable } from "@/components/tables/frameworks/data-table"; +import { FrameworkControlsTable } from "./table/FrameworkControlsTable"; import { useOrganizationCategories } from "../hooks/useOrganizationCategories"; import { useMemo } from "react"; -import type { OrganizationControlType } from "@/components/tables/frameworks/columns"; +import type { OrganizationControlType } from "./table/FrameworkControlsTableColumns"; interface FrameworkControlsProps { frameworkId: string; @@ -34,5 +34,5 @@ export function FrameworkControls({ frameworkId }: FrameworkControlsProps) { return null; } - return ; + return ; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx index 2cd1570724..9701b3f8a9 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx @@ -1,18 +1,12 @@ "use client"; -import { useOrganizationCategories } from "@/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/hooks/useOrganizationCategories"; -import { useOrganizationFramework } from "@/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/hooks/useOrganizationFramework"; -import type { - Control, - Framework, - OrganizationControl, - OrganizationFramework, -} from "@bubba/db"; import { Badge } from "@bubba/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; import { Progress } from "@bubba/ui/progress"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; +import { useOrganizationCategories } from "../hooks/useOrganizationCategories"; +import { useOrganizationFramework } from "../hooks/useOrganizationFramework"; interface FrameworkOverviewProps { frameworkId: string; @@ -22,8 +16,6 @@ export function FrameworkOverview({ frameworkId }: FrameworkOverviewProps) { const { data } = useOrganizationCategories(frameworkId); const { data: framework } = useOrganizationFramework(frameworkId); - console.log({ data }); - // Calculate compliance metrics const totalControls = data?.reduce( (acc, cat) => acc + cat.organizationControl.length, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTable.tsx new file mode 100644 index 0000000000..ac846b897f --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTable.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Loading } from "@/components/tables/risk-tasks/loading"; +import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Suspense } from "react"; +import { + type OrganizationControlType, + FrameworkControlsTableColumns, +} from "./FrameworkControlsTableColumns"; +import { FrameworkControlsTableHeader } from "./FrameworkControlsTableHeader"; + +interface DataTableProps { + data: OrganizationControlType[]; +} + +export function FrameworkControlsTable({ data }: DataTableProps) { + const columns = FrameworkControlsTableColumns(); + + const table = useReactTable({ + data: data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + }> +
+ + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No controls found. + + + )} + +
+
+
+ ); +} diff --git a/apps/app/src/components/tables/frameworks/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableColumns.tsx similarity index 93% rename from apps/app/src/components/tables/frameworks/columns.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableColumns.tsx index 5c9d350bb7..527c32f57a 100644 --- a/apps/app/src/components/tables/frameworks/columns.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableColumns.tsx @@ -18,7 +18,6 @@ import { TooltipProvider, TooltipTrigger, } from "@bubba/ui/tooltip"; -import { useOrganizationControlRequirements } from "@/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/hooks/useOrganizationControlRequirements"; export type OrganizationControlType = { code: string; @@ -62,7 +61,7 @@ function getControlStatus( return "in_progress"; } -export function columns(): ColumnDef[] { +export function FrameworkControlsTableColumns(): ColumnDef[] { const t = useI18n(); return [ diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableHeader.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableHeader.tsx new file mode 100644 index 0000000000..bcb362ba2d --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/components/table/FrameworkControlsTableHeader.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useI18n } from "@/locales/client"; +import { Button } from "@bubba/ui/button"; +import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +type Props = { + table?: { + getIsAllPageRowsSelected: () => boolean; + getIsSomePageRowsSelected: () => boolean; + getAllLeafColumns: () => { + id: string; + getIsVisible: () => boolean; + }[]; + toggleAllPageRowsSelected: (value: boolean) => void; + }; + loading?: boolean; + isEmpty?: boolean; +}; + +export function FrameworkControlsTableHeader({ table, loading }: Props) { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const t = useI18n(); + + const sortParam = searchParams.get("sort"); + const [column, value] = sortParam ? sortParam.split(":") : []; + + const createSortQuery = useCallback( + (name: string) => { + const params = new URLSearchParams(searchParams); + const prevSort = params.get("sort"); + + if (`${name}:asc` === prevSort) { + params.set("sort", `${name}:desc`); + } else if (`${name}:desc` === prevSort) { + params.delete("sort"); + } else { + params.set("sort", `${name}:asc`); + } + + router.replace(`${pathname}?${params.toString()}`); + }, + [searchParams, router, pathname], + ); + + const isVisible = (id: string) => + loading || + table + ?.getAllLeafColumns() + .find((col) => col.id === id) + ?.getIsVisible(); + + return ( + + + {isVisible("name") && ( + + + + )} + + {isVisible("category") && ( + + + + )} + + {isVisible("status") && ( + + + + )} + + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/hooks/useOrganizationFramework.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/hooks/useOrganizationFramework.ts index 2b47ef9918..bc84d39d9f 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/hooks/useOrganizationFramework.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/hooks/useOrganizationFramework.ts @@ -1,14 +1,9 @@ "use client"; import useSWR from "swr"; -import { - type FrameworkWithControls, - getOrganizationFramework, -} from "../actions/getOrganizationFramework"; +import { getOrganizationFramework } from "../actions/getOrganizationFramework"; -async function fetchOrganizationFramework( - frameworkId: string, -): Promise { +async function fetchOrganizationFramework(frameworkId: string) { const result = await getOrganizationFramework({ frameworkId }); if (!result) { @@ -24,13 +19,13 @@ async function fetchOrganizationFramework( } export function useOrganizationFramework(frameworkId: string) { - const { data, error, isLoading, mutate } = useSWR( + const { data, error, isLoading, mutate } = useSWR( ["organization-framework", frameworkId], () => fetchOrganizationFramework(frameworkId), { revalidateOnFocus: false, revalidateOnReconnect: false, - }, + } ); return { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/page.tsx index 16f429cb9e..9fd0ed1385 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/[frameworkId]/page.tsx @@ -1,4 +1,3 @@ -import { auth } from "@/auth"; import { setStaticParamsLocale } from "next-international/server"; import { redirect } from "next/navigation"; import { FrameworkOverview } from "./components/FrameworkOverview"; @@ -12,14 +11,9 @@ interface PageProps { } export default async function FrameworkPage({ params }: PageProps) { - const session = await auth(); const { frameworkId, locale } = await params; setStaticParamsLocale(locale); - if (!session?.user?.organizationId) { - redirect("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/login"); - } - if (!frameworkId) { redirect("/"); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControl.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControl.tsx index d2856793af..cae5e44092 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControl.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControl.tsx @@ -1,24 +1,22 @@ "use client"; import { DisplayFrameworkStatus } from "@/components/frameworks/framework-status"; -import { useOrganizationControl } from "../hooks/useOrganizationControl"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; -import { Label } from "@bubba/ui/label"; -import { Button } from "@bubba/ui/button"; -import { ArrowLeft } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useOrganizationControlProgress } from "../hooks/useOrganizationControlProgress"; -import { DataTable } from "./data-table/data-table"; import { useMemo } from "react"; +import { useOrganizationControl } from "../hooks/useOrganizationControl"; +import { useOrganizationControlProgress } from "../hooks/useOrganizationControlProgress"; +import { ControlRequirementsTable } from "./table/ControlRequirementsTable"; +import { SingleControlSkeleton } from "./SingleControlSkeleton"; interface SingleControlProps { controlId: string; } export const SingleControl = ({ controlId }: SingleControlProps) => { - const router = useRouter(); - const { data: control } = useOrganizationControl(controlId); - const { data: controlProgress } = useOrganizationControlProgress(controlId); + const { data: control, isLoading: isControlLoading } = + useOrganizationControl(controlId); + const { data: controlProgress, isLoading: isControlProgressLoading } = + useOrganizationControlProgress(controlId); const progressStatus = useMemo(() => { if (!controlProgress) return "not_started"; @@ -30,7 +28,12 @@ export const SingleControl = ({ controlId }: SingleControlProps) => { : "completed"; }, [controlProgress]); - if (!control || !controlProgress) return null; + if ( + (!control && isControlLoading) || + (!controlProgress && isControlProgressLoading) + ) { + return ; + } return (
@@ -58,8 +61,10 @@ export const SingleControl = ({ controlId }: SingleControlProps) => {
- {control.OrganizationControlRequirement && ( - + {control?.OrganizationControlRequirement && ( + )}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx new file mode 100644 index 0000000000..c591f2589e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx @@ -0,0 +1,34 @@ +import { CardHeader, CardContent, CardTitle } from "@bubba/ui/card"; + +import { Card } from "@bubba/ui/card"; + +export const SingleControlSkeleton = () => { + return ( +
+
+
+ + +
+ + +
+ + + + + Domain + + +
+ + +
+ +
+
+
+
+
+ ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/columns.tsx deleted file mode 100644 index 6b1a914449..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/columns.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import type { ColumnDef } from "@tanstack/react-table"; -import { CheckCircle2, XCircle } from "lucide-react"; -import type { RequirementTableData } from "./data-table"; - -export const columns: ColumnDef[] = [ - { - id: "type", - accessorKey: "type", - header: "Type", - cell: ({ row }) => row.original.type, - }, - { - id: "description", - accessorKey: "description", - header: "Description", - cell: ({ row }) => ( -
{row.original.description}
- ), - }, - { - id: "status", - accessorKey: "organizationPolicy.status", - header: "Status", - cell: ({ row }) => { - const requirement = row.original; - const isCompleted = - requirement.type === "policy" - ? requirement.organizationPolicy?.status === "published" - : requirement.type === "evidence" - ? requirement.organizationEvidence?.published - : false; - - return ( -
- {isCompleted ? ( - - ) : ( - - )} -
- ); - }, - }, -]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/data-table-header.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/data-table-header.tsx deleted file mode 100644 index 6c1ea1b0c2..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/data-table-header.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; -import type { Table } from "@tanstack/react-table"; -import type { RequirementTableData } from "./data-table"; - -type Props = { - table: Table; - loading?: boolean; -}; - -export function DataTableHeader({ table, loading }: Props) { - const isVisible = (id: string) => - loading || - table - .getAllLeafColumns() - .find((col) => col.id === id) - ?.getIsVisible(); - - return ( - - - {isVisible("type") && ( - - Type - - )} - {isVisible("description") && ( - - Description - - )} - {isVisible("status") && ( - - Status - - )} - - - ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/data-table.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTable.tsx similarity index 84% rename from apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/data-table.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTable.tsx index efe87f5a2c..44de2a9753 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/data-table.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTable.tsx @@ -7,8 +7,8 @@ import { } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; -import { columns } from "./columns"; -import { DataTableHeader } from "./data-table-header"; +import { ControlRequirementsTableColumns } from "./ControlRequirementsTableColumns"; +import { ControlRequirementsTableHeader } from "./ControlRequirementsTableHeader"; import { useRouter } from "next/navigation"; import type { OrganizationControlRequirement, @@ -26,12 +26,12 @@ interface DataTableProps { data: RequirementTableData[]; } -export function DataTable({ data }: DataTableProps) { +export function ControlRequirementsTable({ data }: DataTableProps) { const router = useRouter(); const table = useReactTable({ data, - columns, + columns: ControlRequirementsTableColumns, getCoreRowModel: getCoreRowModel(), }); @@ -59,7 +59,7 @@ export function DataTable({ data }: DataTableProps) {
- + {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( @@ -82,7 +82,7 @@ export function DataTable({ data }: DataTableProps) { ) : ( No requirements found. diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTableColumns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTableColumns.tsx new file mode 100644 index 0000000000..b756d85213 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTableColumns.tsx @@ -0,0 +1,61 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { CheckCircle2, XCircle } from "lucide-react"; +import type { RequirementTableData } from "./ControlRequirementsTable"; + +export const ControlRequirementsTableColumns: ColumnDef[] = + [ + { + id: "type", + accessorKey: "type", + header: "Type", + cell: ({ row }) => row.original.type, + size: 100, + }, + { + id: "description", + accessorKey: "description", + header: "Description", + size: 1000, + cell: ({ row }) => { + const description = row.original.description || ""; // Default to empty string if null + const maxLength = 300; // Increased character limit + const displayText = + description.length > maxLength + ? `${description.substring(0, maxLength)}...` + : description; + + return ( +
+ {displayText} +
+ ); + }, + }, + { + id: "status", + accessorKey: "organizationPolicy.status", + header: "Status", + size: 80, + cell: ({ row }) => { + const requirement = row.original; + const isCompleted = + requirement.type === "policy" + ? requirement.organizationPolicy?.status === "published" + : requirement.type === "evidence" + ? requirement.organizationEvidence?.published + : false; + + return ( +
+ {isCompleted ? ( + + ) : ( + + )} +
+ ); + }, + }, + ]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTableHeader.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTableHeader.tsx new file mode 100644 index 0000000000..70422cc829 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/ControlRequirementsTableHeader.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; +import type { Table } from "@tanstack/react-table"; +import type { RequirementTableData } from "./ControlRequirementsTable"; + +type Props = { + table: Table; + loading?: boolean; +}; + +export function ControlRequirementsTableHeader({ table, loading }: Props) { + const isVisible = (id: string) => + loading || + table + .getAllLeafColumns() + .find((col) => col.id === id) + ?.getIsVisible(); + + return ( + + + {isVisible("type") && ( + + Type + + )} + {isVisible("description") && ( + + Description + + )} + {isVisible("status") && ( + + Status + + )} + + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/types.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/data-table/types.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/table/types.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/page.tsx index 99e9ed8a82..63c5148c36 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/page.tsx @@ -4,26 +4,26 @@ import { setStaticParamsLocale } from "next-international/server"; import { FrameworksOverview } from "./components/FrameworksOverview"; export default async function DashboardPage({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }) { - const { locale } = await params; - setStaticParamsLocale(locale); + const { locale } = await params; + setStaticParamsLocale(locale); - return ; + return ; } export async function generateMetadata({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }): Promise { - const { locale } = await params; - setStaticParamsLocale(locale); - const t = await getI18n(); + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); - return { - title: t("sidebar.overview"), - }; + return { + title: t("sidebar.overview"), + }; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/(overview)/layout.tsx index d6a83f50a7..b43a1a36de 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/(overview)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/(overview)/layout.tsx @@ -2,22 +2,22 @@ import { getI18n } from "@/locales/server"; import { SecondaryMenu } from "@bubba/ui/secondary-menu"; export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const t = await getI18n(); + const t = await getI18n(); - return ( -
- + return ( +
+ -
{children}
-
- ); +
{children}
+
+ ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/layout.tsx index d6a83f50a7..b43a1a36de 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/layout.tsx @@ -2,22 +2,22 @@ import { getI18n } from "@/locales/server"; import { SecondaryMenu } from "@bubba/ui/secondary-menu"; export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const t = await getI18n(); + const t = await getI18n(); - return ( -
- + return ( +
+ -
{children}
-
- ); +
{children}
+
+ ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx index b62f5c00cc..ad24cd48f2 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx @@ -13,7 +13,7 @@ export default async function Layout({ diff --git a/apps/app/src/components/charts/requirement-status.tsx b/apps/app/src/components/charts/requirement-status.tsx deleted file mode 100644 index 02e2f33a4b..0000000000 --- a/apps/app/src/components/charts/requirement-status.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client"; - -import { useI18n } from "@/locales/client"; -import type { - Framework, - OrganizationControl, - OrganizationFramework, - OrganizationControlRequirement, -} from "@bubba/db"; -import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; -import { Progress } from "@bubba/ui/progress"; -import Link from "next/link"; -import { cn } from "@bubba/ui/cn"; -import { useMemo } from "react"; - -interface Props { - frameworks: (OrganizationFramework & { - framework: Framework; - organizationControl: (OrganizationControl & { - OrganizationControlRequirement?: (OrganizationControlRequirement & { - organizationPolicy?: { - status: "draft" | "published" | "archived"; - } | null; - organizationEvidence?: { - published: boolean; - } | null; - })[]; - control?: { - code: string; - name: string; - }; - status?: string; - })[]; - })[]; -} - -export function RequirementStatus({ frameworks }: Props) { - const t = useI18n(); - - // Log the full frameworks structure to see what data we're working with - console.log("Frameworks data:", JSON.stringify(frameworks, null, 2)); - - // Helper function to check if a control is compliant based on its requirements - const isControlCompliant = ( - control: OrganizationControl & { - OrganizationControlRequirement?: (OrganizationControlRequirement & { - organizationPolicy?: { - status: "draft" | "published" | "archived"; - } | null; - organizationEvidence?: { - published: boolean; - } | null; - })[]; - control?: { - code: string; - name: string; - }; - status?: string; - }, - ) => { - // First, check if the control has the direct status of "compliant" - if (control.status === "compliant") { - console.log( - `Control ${control.control?.name || control.id} is directly marked as compliant`, - ); - return true; - } - - // Then check the requirements if they exist - const requirements = control.OrganizationControlRequirement; - - if (!requirements || requirements.length === 0) { - console.log( - `Control ${control.control?.name || control.id} has no requirements`, - ); - return false; - } - - console.log( - `Control ${control.control?.name || control.id} has ${requirements.length} requirements`, - ); - - const totalRequirements = requirements.length; - const completedRequirements = requirements.filter((req) => { - let isCompleted = false; - - switch (req.type) { - case "policy": - isCompleted = req.organizationPolicy?.status === "published"; - break; - case "file": - isCompleted = !!req.fileUrl; - break; - case "evidence": - isCompleted = req.organizationEvidence?.published === true; - break; - default: - isCompleted = req.published || false; - } - - if (isCompleted) { - console.log(`Requirement ${req.id} of type ${req.type} is completed`); - } else { - console.log( - `Requirement ${req.id} of type ${req.type} is NOT completed`, - ); - } - - return isCompleted; - }).length; - - console.log( - `Control has ${completedRequirements}/${totalRequirements} completed requirements`, - ); - - return completedRequirements === totalRequirements; - }; - - // Calculate framework compliance scores based on the requirements - const frameworkCompliance = useMemo(() => { - return frameworks.map((framework) => { - console.log(`Processing framework: ${framework.framework.name}`); - - // Calculate framework controls compliance based on requirements - const totalControls = framework.organizationControl.length; - console.log(`Total controls: ${totalControls}`); - - const compliantControls = framework.organizationControl.filter( - (control) => { - const isCompliant = isControlCompliant(control); - console.log( - `Control ${control.control?.name || control.id} compliant: ${isCompliant}`, - ); - return isCompliant; - }, - ).length; - - console.log(`Compliant controls: ${compliantControls}`); - - // Calculate compliance percentage - const compliance = - totalControls > 0 - ? Math.round((compliantControls / totalControls) * 100) - : 0; - - console.log( - `Framework ${framework.framework.name} compliance: ${compliance}%`, - ); - - return { - id: framework.framework.id, - name: framework.framework.name, - compliance, - }; - }); - }, [frameworks]); - - const isLoading = !frameworks; - - return ( - - - {t("frameworks.title")} - - View All - - - - {isLoading ? ( -
-
-

- Loading compliance data... -

-
- ) : frameworks.length === 0 ? ( -
-

- {t("frameworks.overview.empty.description")} -

-
- ) : ( -
- {/* Framework List */} -
- {frameworkCompliance.map((framework) => { - const compliance = Math.max(0, framework.compliance); - - return ( - -
-
- {framework.name.substring(0, 2).toUpperCase()} -
-
-
-
-

{framework.name}

- - {compliance}% Compliant - -
- -
- - ); - })} -
-
- )} - - - ); -} diff --git a/apps/app/src/components/tables/frameworks/data-table-header.tsx b/apps/app/src/components/tables/frameworks/data-table-header.tsx deleted file mode 100644 index 70f79a3931..0000000000 --- a/apps/app/src/components/tables/frameworks/data-table-header.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; - -import { useI18n } from "@/locales/client"; -import { Button } from "@bubba/ui/button"; -import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; -import { ArrowDown, ArrowUp } from "lucide-react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -type Props = { - table?: { - getIsAllPageRowsSelected: () => boolean; - getIsSomePageRowsSelected: () => boolean; - getAllLeafColumns: () => { - id: string; - getIsVisible: () => boolean; - }[]; - toggleAllPageRowsSelected: (value: boolean) => void; - }; - loading?: boolean; - isEmpty?: boolean; -}; - -export function DataTableHeader({ table, loading }: Props) { - const searchParams = useSearchParams(); - const pathname = usePathname(); - const router = useRouter(); - const t = useI18n(); - - const sortParam = searchParams.get("sort"); - const [column, value] = sortParam ? sortParam.split(":") : []; - - const createSortQuery = useCallback( - (name: string) => { - const params = new URLSearchParams(searchParams); - const prevSort = params.get("sort"); - - if (`${name}:asc` === prevSort) { - params.set("sort", `${name}:desc`); - } else if (`${name}:desc` === prevSort) { - params.delete("sort"); - } else { - params.set("sort", `${name}:asc`); - } - - router.replace(`${pathname}?${params.toString()}`); - }, - [searchParams, router, pathname], - ); - - const isVisible = (id: string) => - loading || - table - ?.getAllLeafColumns() - .find((col) => col.id === id) - ?.getIsVisible(); - - return ( - - - {isVisible("name") && ( - - - - )} - - {isVisible("category") && ( - - - - )} - - {isVisible("status") && ( - - - - )} - - - ); -} diff --git a/apps/app/src/components/tables/frameworks/data-table.tsx b/apps/app/src/components/tables/frameworks/data-table.tsx deleted file mode 100644 index 39d9d1a114..0000000000 --- a/apps/app/src/components/tables/frameworks/data-table.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { Loading } from "@/components/tables/risk-tasks/loading"; -import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; -import { - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { Suspense } from "react"; -import { type OrganizationControlType, columns as getColumns } from "./columns"; -import { DataTableHeader } from "./data-table-header"; - -interface DataTableProps { - data: OrganizationControlType[]; -} - -export function DataTable({ data }: DataTableProps) { - const columns = getColumns(); - - const table = useReactTable({ - data: data, - columns, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - }> -
-
- - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No controls found. - - - )} - -
-
- - ); -} diff --git a/apps/app/src/utils/locale.ts b/apps/app/src/utils/locale.ts new file mode 100644 index 0000000000..f38984bb02 --- /dev/null +++ b/apps/app/src/utils/locale.ts @@ -0,0 +1,27 @@ +import { setStaticParamsLocale } from "next-international/server"; + +/** + * Helper function to set locale from params and return it + * Use this in pages to avoid repetitive locale setup code + */ +export async function setupLocale( + params: Promise<{ locale: string } & Record> +): Promise { + const resolvedParams = await params; + const { locale } = resolvedParams; + setStaticParamsLocale(locale); + return locale; +} + +/** + * Get locale and other params from a params object + * This is useful when you need both the locale and other route parameters + */ +export async function getParamsWithLocale>( + params: Promise<{ locale: string } & T> +): Promise<{ locale: string } & T> { + const resolvedParams = await params; + const { locale } = resolvedParams; + setStaticParamsLocale(locale); + return resolvedParams; +}