diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx
new file mode 100644
index 0000000000..ae10f81153
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/components/policy-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, LabelList } from "recharts"
+
+interface AssigneeData {
+ id: string;
+ name: string;
+ total: number;
+ published: number;
+ draft: number;
+ archived: number;
+ needs_review: number;
+}
+
+interface PolicyAssigneeChartProps {
+ 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 PolicyAssigneeChart({ data }: PolicyAssigneeChartProps) {
+ const t = useI18n();
+
+ if (!data || data.length === 0) {
+ return (
+
+
+
+ {t("policies.dashboard.policies_by_assignee") ||
+ "Policies by Assignee"}
+
+
+
+
+ No policies 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("policies.status.published"),
+ color: CHART_COLORS.published,
+ },
+ draft: {
+ label: t("policies.status.draft"),
+ color: CHART_COLORS.draft,
+ },
+ archived: {
+ label: t("policies.status.archived"),
+ color: CHART_COLORS.archived,
+ },
+ needs_review: {
+ label: t("policies.status.needs_review"),
+ color: CHART_COLORS.needs_review,
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+ {t("policies.dashboard.policies_by_assignee") ||
+ "Policies by Assignee"}
+
+
+
+
+
+
+
+ value.split(' ')[0]}
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {Object.entries(chartConfig).map(([key, config]) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/components/policy-status-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/components/policy-status-chart.tsx
new file mode 100644
index 0000000000..63780e63c7
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/components/policy-status-chart.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import * as React from "react";
+import { Pie, PieChart, Label, Tooltip, ResponsiveContainer } 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 PolicyOverviewData {
+ totalPolicies: number;
+ publishedPolicies: number;
+ draftPolicies: number;
+ archivedPolicies: number;
+ needsReviewPolicies: number;
+}
+
+interface PolicyStatusChartProps {
+ data?: PolicyOverviewData | 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
+};
+
+// Custom tooltip component for the pie chart
+const StatusTooltip = ({ active, payload }: any) => {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
{data.name}
+
+ Count: {data.value}
+
+
+ );
+ }
+ return null;
+};
+
+export function PolicyStatusChart({ data }: PolicyStatusChartProps) {
+ const t = useI18n();
+
+ if (!data) {
+ return (
+
+
+ {t("policies.dashboard.policy_status") || "Policy Status"}
+
+
+
+ No policy data available
+
+
+
+
+
+
+ );
+ }
+
+ const chartData = React.useMemo(() => {
+ const items = [
+ {
+ name: t("policies.status.published"),
+ value: data.publishedPolicies,
+ fill: CHART_COLORS.published,
+ },
+ {
+ name: t("policies.status.draft"),
+ value: data.draftPolicies,
+ fill: CHART_COLORS.draft,
+ },
+ {
+ name: t("policies.status.archived"),
+ value: data.archivedPolicies,
+ fill: CHART_COLORS.archived,
+ },
+ {
+ name: t("policies.status.needs_review"),
+ value: data.needsReviewPolicies,
+ fill: CHART_COLORS.needs_review,
+ },
+ ];
+
+ return items.filter(item => item.value);
+ }, [data, t]);
+
+ const chartConfig = {
+ value: {
+ label: "Count",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+ {t("policies.dashboard.policy_status") || "Policy Status"}
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+ {chartData.map((entry) => (
+
+
+
+ {entry.name}
+
+ ({entry.value})
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/layout.tsx
index ed7c769148..562f820d0b 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/layout.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/layout.tsx
@@ -3,31 +3,25 @@ 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 orgId = user?.organizationId;
- return (
-
-
+ return (
+
+
- {children}
-
- );
+
{children}
+
+ );
}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/loading.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/loading.tsx
new file mode 100644
index 0000000000..08be28684a
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(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("policies.dashboard.policy_status")}
+
+
+
+
+
+
+
+
+ {t("policies.dashboard.policies_by_assignee")}
+
+
+
+
+
+
+
+ );
+}
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 662593c842..bac5ce963d 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
@@ -1,111 +1,162 @@
import { auth } from "@/auth";
-import { PoliciesStatus } from "@/components/policies/charts/policies-status";
-import { PoliciesByAssignee } from "@/components/policies/charts/policies-by-assignee";
import { getI18n } from "@/locales/server";
import { db } from "@bubba/db";
import type { Metadata } from "next";
import { setStaticParamsLocale } from "next-international/server";
-import { unstable_cache } from "next/cache";
-import { redirect } from "next/navigation";
+import { cache, Suspense } from "react";
+import { PolicyStatusChart } from "./components/policy-status-chart";
+import { PolicyAssigneeChart } from "./components/policy-assignee-chart";
export default async function PoliciesOverview({
- params,
+ params,
}: {
- params: Promise<{ locale: string }>;
+ params: Promise<{ locale: string }>;
}) {
- const { locale } = await params;
- setStaticParamsLocale(locale);
-
- const session = await auth();
-
- if (!session?.user?.organizationId) {
- redirect("/onboarding");
- }
-
- const overview = await getPoliciesOverview(session.user.organizationId);
-
- if (overview?.totalPolicies === 0) {
- redirect(`/${session.user.organizationId}/policies/all`);
- }
-
- return (
-
- );
+ const { locale } = await params;
+ setStaticParamsLocale(locale);
+
+ const overview = await getPoliciesOverview();
+
+ return (
+
+
+
+ );
}
-const getPoliciesOverview = unstable_cache(
- async (organizationId: string) => {
- return await db.$transaction(async (tx) => {
- const [
- totalPolicies,
- publishedPolicies,
- draftPolicies,
- archivedPolicies,
- needsReviewPolicies,
- ] = 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,
- status: "archived",
- },
- }),
- tx.organizationPolicy.count({
- where: {
- organizationId,
- status: "needs_review",
- },
- }),
- ]);
-
- return {
- totalPolicies,
- publishedPolicies,
- draftPolicies,
- archivedPolicies,
- needsReviewPolicies,
- };
- });
- },
- ["policies-overview-cache"],
-);
+
+const getPoliciesOverview = cache(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,
+ status: "archived",
+ },
+ }),
+ 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/packages/ui/src/globals.css b/packages/ui/src/globals.css
index 3be49521f6..68c0ab86cd 100644
--- a/packages/ui/src/globals.css
+++ b/packages/ui/src/globals.css
@@ -25,14 +25,11 @@
--ring: 240 5.9% 10%;
--radius: 0.5rem;
- /* Keeping the success/warning/chart colors as they were */
- --success: 142 76% 36%;
- --warning: 45 89% 53%;
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
+ --chart-positive: 142 76% 45%; /* Green: use for published, or reviewed = the items are in a good state */
+ --chart-neutral: 45 89% 53%; /* Yellow: use for draft(s) and actions that need reviewing soon */
+ --chart-warning: 220 14% 53%; /* Gray: deleted, archived - states that have been closed */
+ --chart-destructive: 0 85% 60%; /* Red: use for needs review, something that needs action immediately */
+ --chart-other: 196 80% 45%; /* Blue: use for other states */
--chart-closed: #00DC73;
--chart-pending: #0BA5E9;
@@ -73,11 +70,11 @@
--input: 225 2% 36%;
--ring: 151 100% 43%;
- --chart-1: #2662d9;
- --chart-2: #00DC73;
- --chart-3: #FFC007;
- --chart-4: #af57db;
- --chart-5: #e23670;
+ --chart-positive: 142 76% 45%; /* Green: use for published, or reviewed = the items are in a good state */
+ --chart-neutral: 45 89% 53%; /* Yellow: use for draft(s) and actions that need reviewing soon */
+ --chart-warning: 220 14% 53%; /* Gray: deleted, archived - states that have been closed */
+ --chart-destructive: 0 85% 60%; /* Red: use for needs review, something that needs action immediately */
+ --chart-other: 196 80% 45%; /* Blue: use for other states */
/* Keeping the novel-highlight colors as they were */
--novel-highlight-default: #000000;