From cc3d32a9d6ab58b4e3ccb7e37737f1f243cb90f2 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Mon, 31 Mar 2025 21:28:12 -0400 Subject: [PATCH] Refactor evidence overview and policy components - Implemented a new layout for the evidence overview, including charts for evidence status and assignee data. - Created new components for evidence status and assignee charts, enhancing data visualization. - Removed deprecated evidence overview components and hooks to streamline the codebase. - Updated the loading state for evidence overview to improve user experience. - Adjusted routing and localization for evidence-related pages to ensure consistency across the application. --- .../actions/policies/update-policy-action.ts | 200 ++++++------ .../components/evidence-assignee-chart.tsx | 153 ++++++++++ .../components/evidence-status-chart.tsx | 177 +++++++++++ .../constants/departments.ts | 0 .../constants/evidence-status.ts | 0 .../(overview)/data/getEvidenceDashboard.ts | 218 +++++++++++++ .../hooks/useAssigneeData.ts | 0 .../hooks/useEvidenceData.ts | 0 .../hooks/useFrameworkData.ts | 0 .../hooks/useScrollPosition.tsx | 0 .../hooks/useStatusTranslation.ts | 0 .../[orgId]/evidence/(overview)/layout.tsx | 33 ++ .../[orgId]/evidence/(overview)/loading.tsx | 31 ++ .../[orgId]/evidence/(overview)/page.tsx | 158 ++++++++++ .../[orgId]/evidence/list/layout.tsx | 46 +-- .../overview/components/AnimatedBar.tsx | 26 -- .../AssigneeChart/AssigneeBarChart.tsx | 63 ---- .../AssigneeChart/AssigneeChartRow.tsx | 65 ---- .../DepartmentChart/DepartmentBarChart.tsx | 60 ---- .../DepartmentChart/DepartmentChartRow.tsx | 66 ---- .../overview/components/EvidenceOverview.tsx | 61 ---- .../overview/components/EvidenceUIStates.tsx | 96 ------ .../FrameworkChart/FrameworkBarChart.tsx | 59 ---- .../FrameworkChart/FrameworkChartRow.tsx | 66 ---- .../components/HorizontalBarChart.tsx | 161 ---------- .../overview/data/getEvidenceDashboard.ts | 217 ------------- .../[orgId]/evidence/overview/layout.tsx | 33 -- .../[orgId]/evidence/overview/loading.tsx | 5 - .../[orgId]/evidence/overview/page.tsx | 27 -- .../(dashboard)/[orgId]/evidence/page.tsx | 5 - .../[orgId]/policies/(overview)/page.tsx | 289 +++++++++--------- apps/app/src/app/[locale]/providers.tsx | 4 +- apps/app/src/components/main-menu.tsx | 2 +- apps/app/src/components/user-menu.tsx | 5 +- apps/app/src/locales/features/evidence.ts | 69 +++-- 35 files changed, 1079 insertions(+), 1316 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-assignee-chart.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-status-chart.tsx rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/constants/departments.ts (100%) rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/constants/evidence-status.ts (100%) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/data/getEvidenceDashboard.ts rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/hooks/useAssigneeData.ts (100%) rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/hooks/useEvidenceData.ts (100%) rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/hooks/useFrameworkData.ts (100%) rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/hooks/useScrollPosition.tsx (100%) rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/{overview => (overview)}/hooks/useStatusTranslation.ts (100%) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/layout.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/loading.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AnimatedBar.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeBarChart.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeChartRow.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentBarChart.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentChartRow.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceOverview.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceUIStates.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkBarChart.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkChartRow.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/HorizontalBarChart.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/data/getEvidenceDashboard.ts delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/layout.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/loading.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/page.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/page.tsx diff --git a/apps/app/src/actions/policies/update-policy-action.ts b/apps/app/src/actions/policies/update-policy-action.ts index f75dc79c40..9a3df05107 100644 --- a/apps/app/src/actions/policies/update-policy-action.ts +++ b/apps/app/src/actions/policies/update-policy-action.ts @@ -7,111 +7,111 @@ import { authActionClient } from "../safe-action"; import { updatePolicySchema } from "../schema"; interface ContentNode { - type: string; - content?: ContentNode[]; - text?: string; - attrs?: Record; - marks?: Array<{ type: string; attrs?: Record }>; - [key: string]: any; + type: string; + content?: ContentNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; + [key: string]: any; } // Simplified content processor that creates a new plain object function processContent( - content: ContentNode | ContentNode[] + content: ContentNode | ContentNode[], ): ContentNode | ContentNode[] { - if (!content) return content; - - // Handle arrays - if (Array.isArray(content)) { - return content.map((node) => processContent(node) as ContentNode); - } - - // Create a new plain object with only the necessary properties - const processed: ContentNode = { - type: content.type, - }; - - if (content.text !== undefined) { - processed.text = content.text; - } - - if (content.attrs) { - processed.attrs = { ...content.attrs }; - } - - if (content.marks) { - processed.marks = content.marks.map((mark) => ({ - type: mark.type, - ...(mark.attrs && { attrs: { ...mark.attrs } }), - })); - } - - if (content.content) { - processed.content = processContent(content.content) as ContentNode[]; - } - - return processed; + if (!content) return content; + + // Handle arrays + if (Array.isArray(content)) { + return content.map((node) => processContent(node) as ContentNode); + } + + // Create a new plain object with only the necessary properties + const processed: ContentNode = { + type: content.type, + }; + + if (content.text !== undefined) { + processed.text = content.text; + } + + if (content.attrs) { + processed.attrs = { ...content.attrs }; + } + + if (content.marks) { + processed.marks = content.marks.map((mark) => ({ + type: mark.type, + ...(mark.attrs && { attrs: { ...mark.attrs } }), + })); + } + + if (content.content) { + processed.content = processContent(content.content) as ContentNode[]; + } + + return processed; } export const updatePolicyAction = authActionClient - .schema(updatePolicySchema) - .metadata({ - name: "update-policy", - track: { - event: "update-policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, content } = parsedInput; - const { user } = ctx; - - if (!user) { - return { - success: false, - error: "Not authorized", - }; - } - - try { - const policy = await db.organizationPolicy.findUnique({ - where: { id, organizationId: user.organizationId }, - }); - - if (!policy) { - return { - success: false, - error: "Policy not found", - }; - } - - // Create a new plain object from the content - const processedContent = JSON.parse( - JSON.stringify(processContent(content as ContentNode)) - ); - - await db.organizationPolicy.update({ - where: { id }, - data: { content: processedContent.content }, - }); - - revalidatePath(`/${user.organizationId}/policies/${id}`); - revalidatePath(`/${user.organizationId}/policies`); - revalidateTag(`user_${user.id}`); - - return { - success: true, - }; - } catch (error) { - logger.error("Error updating policy:", { - error, - errorMessage: error instanceof Error ? error.message : "Unknown error", - errorStack: error instanceof Error ? error.stack : undefined, - }); - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to update policy", - }; - } - }); + .schema(updatePolicySchema) + .metadata({ + name: "update-policy", + track: { + event: "update-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, content } = parsedInput; + const { user } = ctx; + + if (!user) { + return { + success: false, + error: "Not authorized", + }; + } + + try { + const policy = await db.organizationPolicy.findUnique({ + where: { id, organizationId: user.organizationId }, + }); + + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } + + // Create a new plain object from the content + const processedContent = JSON.parse( + JSON.stringify(processContent(content as ContentNode)), + ); + + await db.organizationPolicy.update({ + where: { id }, + data: { content: processedContent.content }, + }); + + revalidatePath(`/${user.organizationId}/policies/${id}`); + revalidatePath(`/${user.organizationId}/policies`); + revalidateTag(`user_${user.id}`); + + return { + success: true, + }; + } catch (error) { + logger.error("Error updating policy:", { + error, + errorMessage: error instanceof Error ? error.message : "Unknown error", + errorStack: error instanceof Error ? error.stack : undefined, + }); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to update policy", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-assignee-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-assignee-chart.tsx new file mode 100644 index 0000000000..165135e413 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-assignee-chart.tsx @@ -0,0 +1,153 @@ +"use client"; + +import * as React from "react"; + +import { + Card, + CardContent, + CardHeader, + CardFooter, + CardTitle, +} from "@bubba/ui/card"; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@bubba/ui/chart"; +import { useI18n } from "@/locales/client"; +import { Bar, BarChart, XAxis, YAxis, Legend, ResponsiveContainer } from "recharts" + +interface AssigneeData { + id: string; + name: string; + total: number; + published: number; + draft: number; + archived: number; + needs_review: number; +} + +interface EvidenceAssigneeChartProps { + data?: AssigneeData[] | null; +} + +const CHART_COLORS = { + published: "hsl(var(--chart-positive))", // green + draft: "hsl(var(--chart-neutral))", // yellow + archived: "hsl(var(--chart-warning))", // gray + needs_review: "hsl(var(--chart-destructive))", // red +}; + +export function EvidenceAssigneeChart({ data }: EvidenceAssigneeChartProps) { + const t = useI18n(); + + if (!data || data.length === 0) { + return ( + + + + {t("evidence.dashboard.by_assignee") || + "Evidence by Assignee"} + + + +

+ No evidence assigned to users +

+
+ +
+ + + ); + } + + // Sort assignees by total policies (descending) + const sortedData = React.useMemo(() => { + return [...data].sort((a, b) => b.total - a.total).slice(0, 4).reverse(); + }, [data]); + + const chartData = sortedData.map((item) => ({ + name: item.name, + published: item.published, + draft: item.draft, + archived: item.archived, + needs_review: item.needs_review, + })); + + const chartConfig = { + published: { + label: t("evidence.status.published"), + color: CHART_COLORS.published, + }, + draft: { + label: t("evidence.status.draft"), + color: CHART_COLORS.draft, + }, + archived: { + label: t("evidence.status.isNotRelevant"), + color: CHART_COLORS.archived, + }, + needs_review: { + label: t("evidence.status.needs_review"), + color: CHART_COLORS.needs_review, + }, + } satisfies ChartConfig; + + return ( + + + + {t("evidence.dashboard.by_assignee") || + "Evidence by Assignee"} + + + + + + + + value.split(' ')[0]} + /> + } + /> + + + + + + + + + +
+ {Object.entries(chartConfig).map(([key, config]) => ( +
+
+ {config.label} +
+ ))} +
+ + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-status-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-status-chart.tsx new file mode 100644 index 0000000000..6b5be36302 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/components/evidence-status-chart.tsx @@ -0,0 +1,177 @@ +"use client"; + +import * as React from "react"; +import { Pie, PieChart, Label } from "recharts"; + +import { + Card, + CardContent, + CardHeader, + CardFooter, + CardTitle, +} from "@bubba/ui/card"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from "@bubba/ui/chart"; +import { useI18n } from "@/locales/client"; + +interface EvidenceOverviewData { + totalEvidence: number; + publishedEvidence: number; + draftEvidence: number; + isNotRelevant: number; +} + +interface EvidenceStatusChartProps { + data?: EvidenceOverviewData | null; +} + +const CHART_COLORS = { + published: "hsl(var(--chart-positive))", // green + draft: "hsl(var(--chart-neutral))", // yellow + archived: "hsl(var(--chart-warning))", // gray + needs_review: "hsl(var(--chart-destructive))", // red +}; + +export function EvidenceStatusChart({ data }: EvidenceStatusChartProps) { + const t = useI18n(); + + if (!data) { + return ( + + + {t("evidence.dashboard.evidence_status") || "Evidence Status"} + + +

+ No evidence data available +

+
+ +
+ + + ); + } + + const chartData = React.useMemo(() => { + const items = [ + { + name: t("evidence.status.published"), + value: data.publishedEvidence, + fill: CHART_COLORS.published, + }, + { + name: t("evidence.status.draft"), + value: data.draftEvidence, + fill: CHART_COLORS.draft, + }, + { + name: t("evidence.status.isNotRelevant"), + value: data.isNotRelevant, + fill: CHART_COLORS.archived, + }, + ]; + + return items.filter(item => item.value); + }, [data, t]); + + const chartConfig = { + value: { + label: "Count", + }, + } satisfies ChartConfig; + + return ( + + + {t("evidence.dashboard.evidence_status") || "Evidence Status"} + + + + + } + /> + + + + + + +
+ {chartData.map((entry) => ( +
+
+ + {entry.name} + + ({entry.value}) + + +
+ ))} +
+ + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/constants/departments.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/departments.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/constants/departments.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/departments.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/constants/evidence-status.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/evidence-status.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/constants/evidence-status.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/constants/evidence-status.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/data/getEvidenceDashboard.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/data/getEvidenceDashboard.ts new file mode 100644 index 0000000000..a529a516ee --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/data/getEvidenceDashboard.ts @@ -0,0 +1,218 @@ +import type { Departments, OrganizationEvidence } from "@bubba/db/types"; +import { db } from "@bubba/db"; +import { cache } from "react"; + +export type EvidenceStatus = "empty" | "draft" | "needsReview" | "upToDate"; + +export interface EvidenceWithStatus + extends Omit { + status: EvidenceStatus; + assigneeEmail?: string; +} + +export interface EvidenceDashboardData { + byDepartment: Record; + byAssignee: Record; + byFramework: Record; + unassigned: EvidenceWithStatus[]; + statusCounts: { + totalCount: number; + emptyCount: number; + draftCount: number; + needsReviewCount: number; + upToDateCount: number; + }; +} + +/** + * Get the evidence dashboard data for a given organization + * @param organizationId - The ID of the organization to get the evidence for + * @returns The evidence dashboard data for the given organization + */ +export const getEvidenceDashboard = cache( + async (organizationId: string): Promise => { + const evidence = await db.organizationEvidence.findMany({ + where: { + organizationId, + }, + include: { + assignee: true, + }, + }); + + if (!evidence) { + return null; + } + + // Calculate status for each evidence item + const now = new Date(); + let emptyCount = 0; + let draftCount = 0; + let needsReviewCount = 0; + let upToDateCount = 0; + const totalCount = evidence.length; + + const evidenceWithStatus: EvidenceWithStatus[] = evidence.map((item) => { + // Check if task has files or links + const hasContent = + item.fileUrls.length > 0 || item.additionalUrls.length > 0; + + // Check if task is published + const isPublished = item.published; + + // Check if task needs review (published and next review date is in the past) + let nextReviewDate = null; + + if (item.lastPublishedAt && item.frequency) { + // Calculate next review date based on last published date and frequency + const lastPublished = new Date(item.lastPublishedAt); + nextReviewDate = new Date(lastPublished); + + switch (item.frequency) { + case "monthly": + nextReviewDate.setMonth(nextReviewDate.getMonth() + 1); + break; + case "quarterly": + nextReviewDate.setMonth(nextReviewDate.getMonth() + 3); + break; + case "yearly": + nextReviewDate.setFullYear(nextReviewDate.getFullYear() + 1); + break; + } + } + + const isPastDue = nextReviewDate && nextReviewDate < now; + + let status: EvidenceStatus; + + if (!hasContent && !isPublished) { + // No files or links + status = "empty"; + emptyCount++; + } else if (!isPublished) { + // Has content but not published + status = "draft"; + draftCount++; + } else if (isPastDue) { + // Published but needs review + status = "needsReview"; + needsReviewCount++; + } else { + // Published and up to date + status = "upToDate"; + upToDateCount++; + } + + // Extract assignee email if available + const assigneeEmail = item.assignee?.email || undefined; + + // Create a new object without the assignee property + const { assignee, ...evidenceWithoutAssignee } = item; + + return { + ...evidenceWithoutAssignee, + status, + assigneeEmail, + }; + }); + + // Initialize with all department values from the enum + const departmentValues: Departments[] = [ + "none", + "admin", + "gov", + "hr", + "it", + "itsm", + "qms", + ]; + + // Initialize the byDepartment object with empty arrays for all departments + const initialByDepartment = departmentValues.reduce< + Record + >( + (acc, dept) => { + acc[dept] = []; + return acc; + }, + {} as Record, + ); + + // Group evidence by department + const byDepartment = evidenceWithStatus.reduce< + Record + >((acc, curr) => { + // Use the department from the evidence, or default to 'none' if it's null/undefined + const department = curr.department || "none"; + + // Add the evidence to the appropriate department array + acc[department].push(curr); + return acc; + }, initialByDepartment); + + // Collect unassigned evidence + const unassigned = evidenceWithStatus.filter((item) => !item.assigneeId); + + // Group evidence by assignee + const byAssignee = evidenceWithStatus.reduce< + Record + >((acc, curr) => { + if (!curr.assigneeId || !curr.assigneeEmail) { + return acc; + } + + const email = curr.assigneeEmail; + if (!acc[email]) { + acc[email] = []; + } + acc[email].push(curr); + return acc; + }, {}); + + // Sample the first assignee's data + const firstAssigneeEmail = Object.keys(byAssignee)[0]; + if (firstAssigneeEmail) { + const firstAssigneeItems = byAssignee[firstAssigneeEmail]; + // Count items by status for the first assignee + const statusCounts = { + empty: 0, + draft: 0, + needsReview: 0, + upToDate: 0, + }; + + for (const item of firstAssigneeItems) { + statusCounts[item.status]++; + } + } + + const byFramework = evidenceWithStatus.reduce< + Record + >((acc, curr) => { + const frameworkId = curr.frameworkId; + if (!frameworkId) { + return acc; + } + + if (!acc[frameworkId]) { + acc[frameworkId] = []; + } + acc[frameworkId].push(curr); + return acc; + }, {}); + + return { + byDepartment, + byAssignee, + byFramework, + unassigned, + statusCounts: { + totalCount, + emptyCount, + draftCount, + needsReviewCount, + upToDateCount, + }, + }; + }, +); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useAssigneeData.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useAssigneeData.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useAssigneeData.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useAssigneeData.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useEvidenceData.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useEvidenceData.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useEvidenceData.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useEvidenceData.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useFrameworkData.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useFrameworkData.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useFrameworkData.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useFrameworkData.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useScrollPosition.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useScrollPosition.tsx similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useScrollPosition.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useScrollPosition.tsx diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useStatusTranslation.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useStatusTranslation.ts similarity index 100% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/hooks/useStatusTranslation.ts rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/hooks/useStatusTranslation.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/layout.tsx new file mode 100644 index 0000000000..efd2ff393e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/layout.tsx @@ -0,0 +1,33 @@ +import { auth } from "@/auth"; +import { getI18n } from "@/locales/server"; +import { SecondaryMenu } from "@bubba/ui/secondary-menu"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const t = await getI18n(); + const session = await auth(); + const user = session?.user; + const organizationId = user?.organizationId; + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/loading.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/loading.tsx new file mode 100644 index 0000000000..a2b32cb2ee --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/loading.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; +import { getI18n } from "@/locales/server"; +import { LogoSpinner } from "@/components/logo-spinner"; + +export default async function Loading() { + const t = await getI18n(); + + return ( +
+
+ + + {t("evidence.dashboard.evidence_status")} + + + + + + + + + {t("evidence.dashboard.by_assignee")} + + + + + +
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx new file mode 100644 index 0000000000..506d9ee1a4 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/(overview)/page.tsx @@ -0,0 +1,158 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { cache, Suspense } from "react"; +import { db } from "@bubba/db"; +import { getI18n } from "@/locales/server"; +import type { Metadata } from "next"; +import { setStaticParamsLocale } from "next-international/server"; +import { EvidenceStatusChart } from "./components/evidence-status-chart"; +import { EvidenceAssigneeChart } from "./components/evidence-assignee-chart"; +import Loading from "./loading"; + +export default async function EvidenceOverview({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setStaticParamsLocale(locale); + + const overview = await getEvidenceOverview(); + + return ( + }> +
+
+ + +
+
+
+ ) +} + +const getEvidenceOverview = cache(async () => { + const session = await auth(); + + if (!session?.user?.organizationId) { + return redirect("/"); + } + + const organizationId = session.user.organizationId; + + return await db.$transaction(async (tx) => { + const [totalEvidence, publishedEvidence, draftEvidence, isNotRelevant, evidenceByAssignee, evidenceByAssigneeStatus] = await Promise.all( + [ + tx.organizationEvidence.count({ + where: { + organizationId, + }, + }), + tx.organizationEvidence.count({ + where: { + organizationId, + published: true, + }, + }), + tx.organizationEvidence.count({ + where: { + organizationId, + published: false, + }, + }), + tx.organizationEvidence.count({ + where: { + organizationId, + isNotRelevant: true, + }, + }), + tx.organizationEvidence.groupBy({ + by: ["assigneeId"], + _count: true, + where: { + organizationId, + }, + }), + tx.organizationEvidence.findMany({ + where: { + organizationId, + assigneeId: { not: null }, + }, + select: { + published: true, + isNotRelevant: true, + assignee: { + select: { + id: true, + name: true, + }, + }, + }, + }), + ], + ); + + const evidenceAssigneeByStatus = new Map(); + + for (const evidence of evidenceByAssigneeStatus) { + if (!evidence.assignee) continue; + + const assigneeId = evidence.assignee.id; + + if (!evidenceAssigneeByStatus.has(assigneeId)) { + evidenceAssigneeByStatus.set(assigneeId, { + id: assigneeId, + name: evidence.assignee.name || "Unknown", + total: 0, + published: 0, + draft: 0, + isNotRelevant: 0, + needsReview: 0, + }); + } + + const assigneeData = evidenceAssigneeByStatus.get(assigneeId); + assigneeData.total += 1; + + // status = published if published is true + // status = draft if published is false and isNotRelevant is false + // status = isNotRelevant if published is false and isNotRelevant is true + // status = needsReview if published is true and needs review + + if (evidence.published) { + assigneeData.published += 1; + } else if (evidence.isNotRelevant) { + assigneeData.isNotRelevant += 1; + } else { + // If not published and not irrelevant, it's a draft + assigneeData.draft += 1; + } + } + + const assigneeData = Array.from(evidenceAssigneeByStatus.values()); + + return { + totalEvidence, + publishedEvidence, + draftEvidence, + isNotRelevant, + evidenceByAssignee, + evidenceByAssigneeStatus, + assigneeData, + }; + }); +}); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("sidebar.evidence"), + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/layout.tsx index 12a3e9d9cb..efd2ff393e 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/list/layout.tsx @@ -3,31 +3,31 @@ 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 session = await auth(); - const user = session?.user; - const organizationId = user?.organizationId; + const t = await getI18n(); + const session = await auth(); + const user = session?.user; + const organizationId = user?.organizationId; - return ( -
- + return ( +
+ -
{children}
-
- ); +
{children}
+
+ ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AnimatedBar.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AnimatedBar.tsx deleted file mode 100644 index c0c66e06d3..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AnimatedBar.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; - -export function AnimatedBar({ - index = 0, - children, -}: { - index?: number; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeBarChart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeBarChart.tsx deleted file mode 100644 index 2ffca97a9d..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeBarChart.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { ChevronDown, ChevronUp } from "lucide-react"; -import { useScrollPosition } from "../../hooks/useScrollPosition"; -import { useAssigneeData } from "../../hooks/useAssigneeData"; -import { AssigneeChartRow } from "./AssigneeChartRow"; -import type { EvidenceWithStatus } from "../../data/getEvidenceDashboard"; - -interface AssigneeBarChartProps { - byAssignee?: Record; - unassigned?: EvidenceWithStatus[]; -} - -/** - * Chart component that displays evidence data by assignee - * with horizontal bar charts for each status - */ -export function AssigneeBarChart({ - byAssignee, - unassigned = [], -}: AssigneeBarChartProps) { - const { containerRef, canScrollDown, canScrollUp } = useScrollPosition(); - const { prepareAssigneeData } = useAssigneeData(); - - // Handle empty state - if (!byAssignee && unassigned.length === 0) { - return ( -
-

- No assignee data available -

-
- ); - } - - // Transform data for the chart - const assigneeData = prepareAssigneeData(byAssignee, unassigned); - - return ( -
-
-
- {assigneeData.map((assignee) => ( - - ))} -
-
- - {/* Scroll indicators */} - {canScrollUp && ( -
- -
- )} - - {canScrollDown && ( -
- -
- )} -
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeChartRow.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeChartRow.tsx deleted file mode 100644 index 2f9737bf06..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/AssigneeChart/AssigneeChartRow.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { useI18n } from "@/locales/client"; -import { - STATUS_COLORS, - STATUS_HEX_COLORS, - STATUS_PRIORITY, -} from "../../constants/evidence-status"; -import type { AssigneeData } from "../../hooks/useAssigneeData"; -import { useStatusTranslation } from "../../hooks/useStatusTranslation"; -import { HorizontalBarChart, type ChartDataItem } from "../HorizontalBarChart"; - -interface AssigneeChartRowProps { - assignee: AssigneeData; -} - -/** - * Component for rendering an individual assignee row with chart and legend - */ -export function AssigneeChartRow({ assignee }: AssigneeChartRowProps) { - const t = useI18n(); - const { getStatusLabel } = useStatusTranslation(); - - // Create chart data from assignee status counts - const chartData: ChartDataItem[] = STATUS_PRIORITY.filter( - (status) => assignee.statusCounts[status] > 0, - ).map((status) => ({ - key: status, - label: getStatusLabel(status), - value: assignee.statusCounts[status], - color: STATUS_HEX_COLORS[status], - })); - - return ( -
-
-

{assignee.name}

- - {assignee.totalItems} {t("evidence.items")} - -
- - `${value}`} - height={12} - /> - -
- {STATUS_PRIORITY.map( - (status) => - assignee.statusCounts[status] > 0 && ( -
-
- - {getStatusLabel(status)} ({assignee.statusCounts[status]}) - -
- ), - )} -
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentBarChart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentBarChart.tsx deleted file mode 100644 index 17e38197de..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentBarChart.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import type { Departments } from "@prisma/client"; -import type { EvidenceWithStatus } from "../../data/getEvidenceDashboard"; -import { ChevronDown, ChevronUp } from "lucide-react"; -import { useScrollPosition } from "../../hooks/useScrollPosition"; -import { useEvidenceData } from "../../hooks/useEvidenceData"; -import { DepartmentChartRow } from "./DepartmentChartRow"; - -interface DepartmentBarChartProps { - byDepartment?: Record; -} - -/** - * Chart component that displays evidence data by department - * with horizontal bar charts for each status - */ -export function DepartmentBarChart({ byDepartment }: DepartmentBarChartProps) { - const { containerRef, canScrollDown, canScrollUp } = useScrollPosition(); - const { prepareDepartmentData } = useEvidenceData(); - - // Handle empty state - if (!byDepartment || Object.keys(byDepartment).length === 0) { - return ( -
-

- No department data available -

-
- ); - } - - // Transform data for the chart - const departmentData = prepareDepartmentData(byDepartment); - - return ( -
-
-
- {departmentData.map((department) => ( - - ))} -
-
- - {/* Scroll indicators */} - {canScrollUp && ( -
- -
- )} - - {canScrollDown && ( -
- -
- )} -
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentChartRow.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentChartRow.tsx deleted file mode 100644 index 7b8a03a3c4..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/DepartmentChart/DepartmentChartRow.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import React from "react"; -import { HorizontalBarChart, type ChartDataItem } from "../HorizontalBarChart"; -import { useI18n } from "@/locales/client"; -import type { DepartmentData } from "../../hooks/useEvidenceData"; -import { - STATUS_COLORS, - STATUS_PRIORITY, - STATUS_HEX_COLORS, -} from "../../constants/evidence-status"; -import { useStatusTranslation } from "../../hooks/useStatusTranslation"; - -interface DepartmentChartRowProps { - department: DepartmentData; -} - -/** - * Component for rendering an individual department row with chart and legend - */ -export function DepartmentChartRow({ department }: DepartmentChartRowProps) { - const t = useI18n(); - const { getStatusLabel } = useStatusTranslation(); - - // Create chart data from department status counts - const chartData: ChartDataItem[] = STATUS_PRIORITY.filter( - (status) => department.statusCounts[status] > 0, - ).map((status) => ({ - key: status, - label: getStatusLabel(status), - value: department.statusCounts[status], - color: STATUS_HEX_COLORS[status], - })); - - return ( -
-
-

{department.name}

- - {department.totalItems} {t("evidence.items")} - -
- - `${value}`} - height={12} - /> - -
- {STATUS_PRIORITY.map( - (status) => - department.statusCounts[status] > 0 && ( -
-
- - {getStatusLabel(status)} ({department.statusCounts[status]}) - -
- ), - )} -
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceOverview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceOverview.tsx deleted file mode 100644 index 34d8c76d6d..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceOverview.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { useI18n } from "@/locales/client"; -import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; -import { EvidenceSummaryCards } from "../../list/components/EvidenceSummaryCards"; -import type { EvidenceDashboardData } from "../data/getEvidenceDashboard"; -import { AssigneeBarChart } from "./AssigneeChart/AssigneeBarChart"; -import { DepartmentBarChart } from "./DepartmentChart/DepartmentBarChart"; -import { EvidenceEmptyState } from "./EvidenceUIStates"; -import { FrameworkBarChart } from "./FrameworkChart/FrameworkBarChart"; - -export const EvidenceOverview = ({ - evidence, -}: { - evidence: EvidenceDashboardData | null; -}) => { - const t = useI18n(); - - if (!evidence) { - return ; - } - - const { byDepartment, byAssignee, byFramework, unassigned } = evidence; - - return ( -
- {/* Evidence summary statistics cards */} - - - {/* Charts */} -
- - - {t("evidence.dashboard.by_department")} - - - - - - - - - {t("evidence.dashboard.by_assignee")} - - - - - - - - - {t("evidence.dashboard.by_framework")} - - - - - -
-
- ); -}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceUIStates.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceUIStates.tsx deleted file mode 100644 index d097405bdf..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/EvidenceUIStates.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; -import { Skeleton } from "@bubba/ui/skeleton"; -import { AlertCircle, FileQuestion } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@bubba/ui/alert"; - -// Component for chart skeleton loading state -export const ChartSkeleton = ({ height = "h-64" }: { height?: string }) => ( -
-
- - -
- -
-); - -export const EvidenceOverviewSkeleton = () => { - return ( -
- {/* Summary cards skeleton */} -
- {["empty", "draft", "review", "uptodate"].map((type) => ( - - ))} -
- -
- {/* Department Chart Skeleton */} - - - - - - - - - - - - {/* Assignee Chart Skeleton */} - - - - - - - - - - - - {/* Framework Chart Skeleton */} - - - - - - - - - - -
-
- ); -}; - -interface ErrorStateProps { - message?: string; -} - -export const EvidenceErrorState = ({ message }: ErrorStateProps) => { - return ( - - - Error - - {message || "Failed to load the evidence dashboard"} - - - ); -}; - -export const EvidenceEmptyState = () => { - return ( - - - No Data - - No evidence data is currently available. Try refreshing or check back - later. - - - ); -}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkBarChart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkBarChart.tsx deleted file mode 100644 index 1bf8c90eca..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkBarChart.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { ChevronDown, ChevronUp } from "lucide-react"; -import type { EvidenceWithStatus } from "../../data/getEvidenceDashboard"; -import { useFrameworkData } from "../../hooks/useFrameworkData"; -import { useScrollPosition } from "../../hooks/useScrollPosition"; -import { FrameworkChartRow } from "./FrameworkChartRow"; - -interface FrameworkBarChartProps { - byFramework?: Record; -} - -/** - * Chart component that displays evidence data by framework - * with horizontal bar charts for each status - */ -export function FrameworkBarChart({ byFramework }: FrameworkBarChartProps) { - const { containerRef, canScrollDown, canScrollUp } = useScrollPosition(); - const { prepareFrameworkData } = useFrameworkData(); - - // Handle empty state - if (!byFramework || Object.keys(byFramework).length === 0) { - return ( -
-

- No framework data available -

-
- ); - } - - // Transform data for the chart - const frameworkData = prepareFrameworkData(byFramework); - - return ( -
-
-
- {frameworkData.map((framework) => ( - - ))} -
-
- - {/* Scroll indicators */} - {canScrollUp && ( -
- -
- )} - - {canScrollDown && ( -
- -
- )} -
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkChartRow.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkChartRow.tsx deleted file mode 100644 index 61c49f7c39..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/FrameworkChart/FrameworkChartRow.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import React from "react"; -import { HorizontalBarChart, type ChartDataItem } from "../HorizontalBarChart"; -import { useI18n } from "@/locales/client"; -import type { FrameworkData } from "../../hooks/useFrameworkData"; -import { - STATUS_PRIORITY, - STATUS_COLORS, - STATUS_HEX_COLORS, -} from "../../constants/evidence-status"; -import { useStatusTranslation } from "../../hooks/useStatusTranslation"; - -interface FrameworkChartRowProps { - framework: FrameworkData; -} - -/** - * Component for rendering an individual framework row with chart and legend - */ -export function FrameworkChartRow({ framework }: FrameworkChartRowProps) { - const t = useI18n(); - const { getStatusLabel } = useStatusTranslation(); - - // Create chart data from framework status counts - const chartData: ChartDataItem[] = STATUS_PRIORITY.filter( - (status) => framework.statusCounts[status] > 0, - ).map((status) => ({ - key: status, - label: getStatusLabel(status), - value: framework.statusCounts[status], - color: STATUS_HEX_COLORS[status], - })); - - return ( -
-
-

{framework.name}

- - {framework.totalItems} {t("evidence.items")} - -
- - `${value}`} - height={12} - /> - -
- {STATUS_PRIORITY.map( - (status) => - framework.statusCounts[status] > 0 && ( -
-
- - {getStatusLabel(status)} ({framework.statusCounts[status]}) - -
- ), - )} -
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/HorizontalBarChart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/HorizontalBarChart.tsx deleted file mode 100644 index 38ddd14b1f..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/components/HorizontalBarChart.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import React, { useEffect } from "react"; -import type { CSSProperties, Key } from "react"; - -export interface ChartDataItem { - key: K; - label: string; - value: number; - color?: string; -} - -interface HorizontalBarChartProps { - data: ChartDataItem[]; - title?: string; - height?: number; - showZeroValues?: boolean; - colors?: string[]; - valueFormatter?: (value: number) => string; - showLegend?: boolean; -} - -// Default array of colors using Tailwind's HSL color palette -const defaultColors = [ - "#8b5cf6", // Purple (primary) - "#06b6d4", // Cyan (secondary) - "#10b981", // Emerald (accent) - "#ef4444", // Red (destructive) - "#6b7280", // Gray (muted) -]; - -export function HorizontalBarChart({ - data, - title, - height = 12, // Default bar height matching policies-by-assignee - showZeroValues = true, - colors = defaultColors, - valueFormatter = (value) => `${value}`, - showLegend = false, -}: HorizontalBarChartProps) { - // Debug: Log the incoming data - useEffect(() => { - console.log("HorizontalBarChart - data:", data); - }, [data]); - - if (!data || data.length === 0) { - return
No data available
; - } - - // Assign colors if not provided - const chartData = data.map((item, index) => { - // For single color arrays, use that color for all bars - const color = - colors.length === 1 - ? colors[0] - : item.color || colors[index % colors.length]; - - return { - ...item, - color, - }; - }); - - // Filter out zero values if not showing them - const filteredData = showZeroValues - ? chartData - : chartData.filter((d) => d.value > 0); - - // Debug: Log the filtered data - useEffect(() => { - console.log("HorizontalBarChart - filteredData:", filteredData); - }, [filteredData]); - - // Calculate the total value for percentage calculations - const totalValue = filteredData.reduce((sum, item) => sum + item.value, 0); - - if (totalValue === 0) { - return
; - } - - const barHeight = height; - const gap = 0.3; // Add gap between segments like in policies-by-assignee - const totalWidth = totalValue + gap * (filteredData.length - 1); - let cumulativeWidth = 0; - const cornerRadius = 0; // No rounded corners in the policies-by-assignee chart - - return ( -
- {title &&

{title}

} - -
-
- {filteredData.map((d, index) => { - const barWidth = (d.value / totalWidth) * 100; - const xPosition = cumulativeWidth; - cumulativeWidth += barWidth + gap; - - return ( -
-
-
- ); - })} -
-
- - {/* Legend */} - {showLegend && ( -
- {filteredData.map((item) => ( -
-
- - {item.label} ({valueFormatter(item.value)}) - -
- ))} -
- )} -
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/data/getEvidenceDashboard.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/data/getEvidenceDashboard.ts deleted file mode 100644 index 4c7a7d26a7..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/data/getEvidenceDashboard.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { Departments, OrganizationEvidence } from "@bubba/db/types"; -import { db } from "@bubba/db"; - -export type EvidenceStatus = "empty" | "draft" | "needsReview" | "upToDate"; - -export interface EvidenceWithStatus - extends Omit { - status: EvidenceStatus; - assigneeEmail?: string; -} - -export interface EvidenceDashboardData { - byDepartment: Record; - byAssignee: Record; - byFramework: Record; - unassigned: EvidenceWithStatus[]; - statusCounts: { - totalCount: number; - emptyCount: number; - draftCount: number; - needsReviewCount: number; - upToDateCount: number; - }; -} - -/** - * Get the evidence dashboard data for a given organization - * @param organizationId - The ID of the organization to get the evidence for - * @returns The evidence dashboard data for the given organization - */ -export const getEvidenceDashboard = async ( - organizationId: string -): Promise => { - const evidence = await db.organizationEvidence.findMany({ - where: { - organizationId, - }, - include: { - assignee: true, - }, - }); - - if (!evidence) { - return null; - } - - // Calculate status for each evidence item - const now = new Date(); - let emptyCount = 0; - let draftCount = 0; - let needsReviewCount = 0; - let upToDateCount = 0; - const totalCount = evidence.length; - - const evidenceWithStatus: EvidenceWithStatus[] = evidence.map((item) => { - // Check if task has files or links - const hasContent = - item.fileUrls.length > 0 || item.additionalUrls.length > 0; - - // Check if task is published - const isPublished = item.published; - - // Check if task needs review (published and next review date is in the past) - let nextReviewDate = null; - - if (item.lastPublishedAt && item.frequency) { - // Calculate next review date based on last published date and frequency - const lastPublished = new Date(item.lastPublishedAt); - nextReviewDate = new Date(lastPublished); - - switch (item.frequency) { - case "monthly": - nextReviewDate.setMonth(nextReviewDate.getMonth() + 1); - break; - case "quarterly": - nextReviewDate.setMonth(nextReviewDate.getMonth() + 3); - break; - case "yearly": - nextReviewDate.setFullYear(nextReviewDate.getFullYear() + 1); - break; - } - } - - const isPastDue = nextReviewDate && nextReviewDate < now; - - let status: EvidenceStatus; - - if (!hasContent && !isPublished) { - // No files or links - status = "empty"; - emptyCount++; - } else if (!isPublished) { - // Has content but not published - status = "draft"; - draftCount++; - } else if (isPastDue) { - // Published but needs review - status = "needsReview"; - needsReviewCount++; - } else { - // Published and up to date - status = "upToDate"; - upToDateCount++; - } - - // Extract assignee email if available - const assigneeEmail = item.assignee?.email || undefined; - - // Create a new object without the assignee property - const { assignee, ...evidenceWithoutAssignee } = item; - - return { - ...evidenceWithoutAssignee, - status, - assigneeEmail, - }; - }); - - // Initialize with all department values from the enum - const departmentValues: Departments[] = [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms", - ]; - - // Initialize the byDepartment object with empty arrays for all departments - const initialByDepartment = departmentValues.reduce< - Record - >( - (acc, dept) => { - acc[dept] = []; - return acc; - }, - {} as Record - ); - - // Group evidence by department - const byDepartment = evidenceWithStatus.reduce< - Record - >((acc, curr) => { - // Use the department from the evidence, or default to 'none' if it's null/undefined - const department = curr.department || "none"; - - // Add the evidence to the appropriate department array - acc[department].push(curr); - return acc; - }, initialByDepartment); - - // Collect unassigned evidence - const unassigned = evidenceWithStatus.filter((item) => !item.assigneeId); - - // Group evidence by assignee - const byAssignee = evidenceWithStatus.reduce< - Record - >((acc, curr) => { - if (!curr.assigneeId || !curr.assigneeEmail) { - return acc; - } - - const email = curr.assigneeEmail; - if (!acc[email]) { - acc[email] = []; - } - acc[email].push(curr); - return acc; - }, {}); - - // Sample the first assignee's data - const firstAssigneeEmail = Object.keys(byAssignee)[0]; - if (firstAssigneeEmail) { - const firstAssigneeItems = byAssignee[firstAssigneeEmail]; - // Count items by status for the first assignee - const statusCounts = { - empty: 0, - draft: 0, - needsReview: 0, - upToDate: 0, - }; - - for (const item of firstAssigneeItems) { - statusCounts[item.status]++; - } - } - - const byFramework = evidenceWithStatus.reduce< - Record - >((acc, curr) => { - const frameworkId = curr.frameworkId; - if (!frameworkId) { - return acc; - } - - if (!acc[frameworkId]) { - acc[frameworkId] = []; - } - acc[frameworkId].push(curr); - return acc; - }, {}); - - return { - byDepartment, - byAssignee, - byFramework, - unassigned, - statusCounts: { - totalCount, - emptyCount, - draftCount, - needsReviewCount, - upToDateCount, - }, - }; -}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/layout.tsx deleted file mode 100644 index 12a3e9d9cb..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/layout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { auth } from "@/auth"; -import { getI18n } from "@/locales/server"; -import { SecondaryMenu } from "@bubba/ui/secondary-menu"; - -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const t = await getI18n(); - const session = await auth(); - const user = session?.user; - const organizationId = user?.organizationId; - - return ( -
- - -
{children}
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/loading.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/loading.tsx deleted file mode 100644 index 86c68be725..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { EvidenceOverviewSkeleton } from "./components/EvidenceUIStates"; - -export default function Loading() { - return ; -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/page.tsx deleted file mode 100644 index 27cab8b7d9..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/overview/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { auth } from "@/auth"; -import { redirect } from "next/navigation"; -import { EvidenceOverview } from "./components/EvidenceOverview"; -import { getEvidenceDashboard } from "./data/getEvidenceDashboard"; -import { EvidenceErrorState } from "./components/EvidenceUIStates"; - -export default async function EvidenceOverviewPage() { - const session = await auth(); - - if (!session) { - return redirect("/"); - } - - if (!session.user.organizationId) { - return redirect("/"); - } - - try { - const evidence = await getEvidenceDashboard(session.user.organizationId); - - return ; - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "An unexpected error occurred"; - return ; - } -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/page.tsx deleted file mode 100644 index 3668e837f5..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function EvidencePage() { - redirect("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/evidence/overview"); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx index 22be801aaf..ff5d703e33 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx @@ -6,161 +6,162 @@ import { setStaticParamsLocale } from "next-international/server"; import { Suspense } from "react"; import { PolicyStatusChart } from "./components/policy-status-chart"; import { PolicyAssigneeChart } from "./components/policy-assignee-chart"; +import Loading from "./loading"; export default async function PoliciesOverview({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }) { - const { locale } = await params; - setStaticParamsLocale(locale); - - const overview = await getPoliciesOverview(); - - return ( - -
-
- - -
-
-
- ); + const { locale } = await params; + setStaticParamsLocale(locale); + + const overview = await getPoliciesOverview(); + + return ( + }> +
+
+ + +
+
+
+ ); } const getPoliciesOverview = async () => { - const session = await auth(); - - if (!session?.user?.organizationId) { - return null; - } - - const organizationId = session.user.organizationId; - - return await db.$transaction(async (tx) => { - const [ - totalPolicies, - publishedPolicies, - draftPolicies, - archivedPolicies, - needsReviewPolicies, - policiesByAssignee, - policiesByAssigneeStatus, - ] = await Promise.all([ - tx.organizationPolicy.count({ - where: { - organizationId, - }, - }), - tx.organizationPolicy.count({ - where: { - organizationId, - status: "published", - }, - }), - tx.organizationPolicy.count({ - where: { - organizationId, - status: "draft", - }, - }), - tx.organizationPolicy.count({ - where: { - organizationId, - isArchived: true, - }, - }), - tx.organizationPolicy.count({ - where: { - organizationId, - status: "needs_review", - }, - }), - tx.organizationPolicy.groupBy({ - by: ["ownerId"], - _count: true, - where: { - organizationId, - ownerId: { not: null }, - }, - }), - tx.organizationPolicy.findMany({ - where: { - organizationId, - ownerId: { not: null }, - }, - select: { - status: true, - owner: { - select: { - id: true, - name: true, - }, - }, - }, - }), - ]); - - // Transform the data for easier consumption by the chart component - // First group by owner - const policyDataByOwner = new Map(); - - for (const policy of policiesByAssigneeStatus) { - if (!policy.owner) continue; - - const ownerId = policy.owner.id; - if (!policyDataByOwner.has(ownerId)) { - policyDataByOwner.set(ownerId, { - id: ownerId, - name: policy.owner.name || "Unknown", - total: 0, - published: 0, - draft: 0, - archived: 0, - needs_review: 0, - }); - } - - const ownerData = policyDataByOwner.get(ownerId); - ownerData.total += 1; - - // Handle each status type explicitly - const status = policy.status as - | "published" - | "draft" - | "archived" - | "needs_review"; - if (status === "published") ownerData.published += 1; - else if (status === "draft") ownerData.draft += 1; - else if (status === "archived") ownerData.archived += 1; - else if (status === "needs_review") ownerData.needs_review += 1; - } - - const assigneeData = Array.from(policyDataByOwner.values()); - - return { - totalPolicies, - publishedPolicies, - draftPolicies, - archivedPolicies, - needsReviewPolicies, - policiesByAssignee, - assigneeData, - }; - }); + const session = await auth(); + + if (!session?.user?.organizationId) { + return null; + } + + const organizationId = session.user.organizationId; + + return await db.$transaction(async (tx) => { + const [ + totalPolicies, + publishedPolicies, + draftPolicies, + archivedPolicies, + needsReviewPolicies, + policiesByAssignee, + policiesByAssigneeStatus, + ] = await Promise.all([ + tx.organizationPolicy.count({ + where: { + organizationId, + }, + }), + tx.organizationPolicy.count({ + where: { + organizationId, + status: "published", + }, + }), + tx.organizationPolicy.count({ + where: { + organizationId, + status: "draft", + }, + }), + tx.organizationPolicy.count({ + where: { + organizationId, + isArchived: true, + }, + }), + tx.organizationPolicy.count({ + where: { + organizationId, + status: "needs_review", + }, + }), + tx.organizationPolicy.groupBy({ + by: ["ownerId"], + _count: true, + where: { + organizationId, + ownerId: { not: null }, + }, + }), + tx.organizationPolicy.findMany({ + where: { + organizationId, + ownerId: { not: null }, + }, + select: { + status: true, + owner: { + select: { + id: true, + name: true, + }, + }, + }, + }), + ]); + + // Transform the data for easier consumption by the chart component + // First group by owner + const policyDataByOwner = new Map(); + + for (const policy of policiesByAssigneeStatus) { + if (!policy.owner) continue; + + const ownerId = policy.owner.id; + if (!policyDataByOwner.has(ownerId)) { + policyDataByOwner.set(ownerId, { + id: ownerId, + name: policy.owner.name || "Unknown", + total: 0, + published: 0, + draft: 0, + archived: 0, + needs_review: 0, + }); + } + + const ownerData = policyDataByOwner.get(ownerId); + ownerData.total += 1; + + // Handle each status type explicitly + const status = policy.status as + | "published" + | "draft" + | "archived" + | "needs_review"; + if (status === "published") ownerData.published += 1; + else if (status === "draft") ownerData.draft += 1; + else if (status === "archived") ownerData.archived += 1; + else if (status === "needs_review") ownerData.needs_review += 1; + } + + const assigneeData = Array.from(policyDataByOwner.values()); + + return { + totalPolicies, + publishedPolicies, + draftPolicies, + archivedPolicies, + needsReviewPolicies, + policiesByAssignee, + assigneeData, + }; + }); }; 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.policies"), - }; + return { + title: t("sidebar.policies"), + }; } diff --git a/apps/app/src/app/[locale]/providers.tsx b/apps/app/src/app/[locale]/providers.tsx index f2e2b5527d..81e4292011 100644 --- a/apps/app/src/app/[locale]/providers.tsx +++ b/apps/app/src/app/[locale]/providers.tsx @@ -21,8 +21,8 @@ export function Providers({ children, locale }: ProviderProps) { diff --git a/apps/app/src/components/main-menu.tsx b/apps/app/src/components/main-menu.tsx index 7fb5d2ddc3..97b68beaed 100644 --- a/apps/app/src/components/main-menu.tsx +++ b/apps/app/src/components/main-menu.tsx @@ -121,7 +121,7 @@ export function MainMenu({ }, { id: "evidence", - path: "/:organizationId/evidence/overview", + path: "/:organizationId/evidence", name: t("sidebar.evidence"), disabled: false, icon: Icons.Evidence, diff --git a/apps/app/src/components/user-menu.tsx b/apps/app/src/components/user-menu.tsx index 6b0a7732ab..8d81bade4d 100644 --- a/apps/app/src/components/user-menu.tsx +++ b/apps/app/src/components/user-menu.tsx @@ -74,7 +74,7 @@ export async function UserMenu({ onlySignOut }: { onlySignOut?: boolean }) { */} -
+ {/*

{t("user_menu.theme")}

{" "} @@ -82,8 +82,7 @@ export async function UserMenu({ onlySignOut }: { onlySignOut?: boolean }) {

{t("user_menu.language")}

-
{" "} - +
{" "} */} )} diff --git a/apps/app/src/locales/features/evidence.ts b/apps/app/src/locales/features/evidence.ts index 7262b469d4..94c41920a6 100644 --- a/apps/app/src/locales/features/evidence.ts +++ b/apps/app/src/locales/features/evidence.ts @@ -1,36 +1,39 @@ export const evidence = { - title: "Evidence", - list: "All Evidence", - overview: "Evidence Overview", - edit: "Edit Evidence", - dashboard: { - layout: "Dashboard", - title: "Evidence Dashboard", - by_department: "By Department", - by_assignee: "By Assignee", - by_framework: "By Framework", - }, - items: "items", - status: { - up_to_date: "Up to Date", - needs_review: "Needs Review", - draft: "Draft", - empty: "Empty", - }, - departments: { - none: "Uncategorized", - admin: "Administration", - gov: "Governance", - hr: "Human Resources", - it: "Information Technology", - itsm: "IT Service Management", - qms: "Quality Management", - }, - details: { - content: "Evidence Content", - }, -} as const + title: "Evidence", + list: "All Evidence", + overview: "Evidence Overview", + edit: "Edit Evidence", + dashboard: { + layout: "Overview", + title: "Evidence Dashboard", + by_department: "By Department", + by_assignee: "Evidence Tasks By Assignee", + by_framework: "By Framework", + evidence_status: "Evidence Status", + }, + items: "items", + status: { + published: "Published", + up_to_date: "Up to Date", + needs_review: "Needs Review", + draft: "Draft", + empty: "Empty", + isNotRelevant: "Is Not Relevant", + }, + departments: { + none: "Uncategorized", + admin: "Administration", + gov: "Governance", + hr: "Human Resources", + it: "Information Technology", + itsm: "IT Service Management", + qms: "Quality Management", + }, + details: { + content: "Evidence Content", + }, +} as const; export const evidence_tasks = { - overview: "Overview", -} as const \ No newline at end of file + overview: "Overview", +} as const;