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]) => ( +
+
+ {config.label} +
+ ))} +
+ + + ); +} 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;