From eaaa8fb4ce28b1c84fb7755f9b4ddf7d15b00693 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Wed, 19 Mar 2025 14:19:36 -0400 Subject: [PATCH 1/7] cloned risk --- .../(dashboard)/vendors/(overview)/layout.tsx | 28 +- .../(dashboard)/vendors/(overview)/page.tsx | 89 ++++--- .../vendors/[riskId]/comments/page.tsx | 86 ++++++ .../(dashboard)/vendors/[riskId]/layout.tsx | 60 +++++ .../(dashboard)/vendors/[riskId]/page.tsx | 251 ++++++++++++++++++ .../[taskId]/actions/getTaskAttachments.ts | 61 +++++ .../[taskId]/hooks/useTaskAttachments.ts | 29 ++ .../vendors/[riskId]/tasks/[taskId]/page.tsx | 87 ++++++ .../vendors/[riskId]/tasks/search-params.ts | 14 + .../vendors/register/RiskRegisterTable.tsx | 128 +++++++++ .../vendors/register/actions/getRisks.ts | 65 +++++ .../components/table/RiskRegisterColumns.tsx | 56 ++++ .../components/table/RiskRegisterFilters.tsx | 72 +++++ .../vendors/register/hooks/useRisks.ts | 61 +++++ .../(dashboard)/vendors/register/layout.tsx | 23 ++ .../(dashboard)/vendors/register/page.tsx | 22 ++ .../vendors/register/search-params.ts | 15 ++ 17 files changed, 1094 insertions(+), 53 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/comments/page.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/layout.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/page.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/actions/getTaskAttachments.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/hooks/useTaskAttachments.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/page.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/search-params.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/RiskRegisterTable.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/actions/getRisks.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterColumns.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterFilters.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/hooks/useRisks.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/layout.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/page.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/search-params.ts diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx index ad24cd48f2..71699d2dc1 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/layout.tsx @@ -2,22 +2,22 @@ import { getI18n } from "@/locales/server"; import { SecondaryMenu } from "@bubba/ui/secondary-menu"; export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const t = await getI18n(); + const t = await getI18n(); - return ( -
- + return ( +
+ -
{children}
-
- ); +
{children}
+
+ ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx index 02e27d1ec7..bd4d24bd77 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx @@ -1,5 +1,6 @@ import { auth } from "@/auth"; -import { VendorOverview } from "@/components/vendors/charts/vendors-overview"; +import { RiskOverview } from "@/components/risks/charts/risk-overview"; +import { RisksAssignee } from "@/components/risks/charts/risks-assignee"; import { getI18n } from "@/locales/server"; import { db } from "@bubba/db"; import type { Metadata } from "next"; @@ -7,56 +8,66 @@ import { setStaticParamsLocale } from "next-international/server"; import { unstable_cache } from "next/cache"; import { redirect } from "next/navigation"; -export default async function VendorOverviewPage({ - params, +export default async function RiskManagement({ + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }) { - const { locale } = await params; - setStaticParamsLocale(locale); + const { locale } = await params; + setStaticParamsLocale(locale); - const session = await auth(); + const session = await auth(); - if (!session?.user?.organizationId) { - redirect("/onboarding"); - } + if (!session?.user?.organizationId) { + redirect("/onboarding"); + } - const overview = await getVendorOverview(session.user.organizationId); + const overview = await getRiskOverview(session.user.organizationId); - return ( -
- -
- ); + if (overview?.risks === 0) { + redirect("/risk/register"); + } + + return ( +
+
+ +
+ +
+ +
+
+ ); } -const getVendorOverview = unstable_cache( - async (organizationId: string) => { - return await db.$transaction(async (tx) => { - const [vendors] = await Promise.all([ - tx.vendor.count({ - where: { organizationId }, - }), - ]); - - return { - vendors, - }; - }); - }, - ["vendor-overview-cache"], +const getRiskOverview = unstable_cache( + async (organizationId: string) => { + return await db.$transaction(async (tx) => { + const [risks] = await Promise.all([ + tx.risk.count({ + where: { organizationId }, + }), + ]); + + return { + risks, + }; + }); + }, + ["risk-overview-cache"], ); 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.vendors"), - }; + return { + title: t("sidebar.risk"), + }; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/comments/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/comments/page.tsx new file mode 100644 index 0000000000..b1470f9224 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/comments/page.tsx @@ -0,0 +1,86 @@ +import { auth } from "@/auth"; +import { RiskComments } from "@/components/risks/risk-comments"; +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"; + +interface PageProps { + params: Promise<{ riskId: string }>; +} + +export default async function RiskPage({ params }: PageProps) { + const session = await auth(); + const { riskId } = await params; + + if (!session) { + redirect("/auth"); + } + + if (!session.user.organizationId || !riskId) { + redirect("/"); + } + + const risk = await getRisk(riskId, session.user.organizationId); + + if (!risk) { + redirect("/risk"); + } + + const users = await getUsers(session.user.organizationId); + + return ( +
+ +
+ ); +} + +const getRisk = unstable_cache( + async (riskId: string, organizationId: string) => { + const risk = await db.risk.findUnique({ + where: { + id: riskId, + organizationId: organizationId, + }, + include: { + owner: true, + comments: { + orderBy: { + createdAt: "desc", + }, + }, + }, + }); + + return risk; + }, + ["risk-cache"], +); + +const getUsers = unstable_cache( + async (organizationId: string) => { + const users = await db.user.findMany({ + where: { organizationId: organizationId }, + }); + + return users; + }, + ["users-cache"], +); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("sub_pages.risk.risk_comments"), + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/layout.tsx new file mode 100644 index 0000000000..f69ab0e564 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/layout.tsx @@ -0,0 +1,60 @@ +import { auth } from "@/auth"; +import { Title } from "@/components/title"; +import { getI18n } from "@/locales/server"; +import { db } from "@bubba/db"; +import { SecondaryMenu } from "@bubba/ui/secondary-menu"; +import { unstable_cache } from "next/cache"; +import { redirect } from "next/navigation"; + +interface LayoutProps { + children: React.ReactNode; + params: Promise<{ riskId: string }>; +} + +export default async function Layout({ children, params }: LayoutProps) { + const t = await getI18n(); + const session = await auth(); + + if (!session || !session.user.organizationId) { + redirect("/"); + } + + const riskId = await params; + const risk = await getRisk(riskId.riskId, session.user.organizationId); + + if (!risk) { + redirect("/risk"); + } + + return ( +
+ + +
{children}
+
+ ); +} + +const getRisk = unstable_cache( + async (riskId: string, organizationId: string) => { + const risk = await db.risk.findUnique({ + where: { + id: riskId, + organizationId: organizationId, + }, + }); + + return risk; + }, + ["risk-cache"], +); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/page.tsx new file mode 100644 index 0000000000..763bc54db4 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/page.tsx @@ -0,0 +1,251 @@ +import { auth } from "@/auth"; +import { Loading } from "@/components/frameworks/loading"; +import { InherentRiskChart } from "@/components/risks/charts/inherent-risk-chart"; +import { ResidualRiskChart } from "@/components/risks/charts/residual-risk-chart"; +import { RiskOverview } from "@/components/risks/risk-overview"; +import type { RiskTaskType } from "@/components/tables/risk-tasks/columns"; +import { DataTable } from "@/components/tables/risk-tasks/data-table"; +import { + NoResults, + NoTasks, +} from "@/components/tables/risk-tasks/empty-states"; +import { FilterToolbar } from "@/components/tables/risk-tasks/filter-toolbar"; +import { getServerColumnHeaders } from "@/components/tables/risk-tasks/server-columns"; +import { getI18n } from "@/locales/server"; +import { db } from "@bubba/db"; +import type { RiskTaskStatus } from "@bubba/db/types"; +import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; +import type { Metadata } from "next"; +import { setStaticParamsLocale } from "next-international/server"; +import { unstable_cache } from "next/cache"; +import { redirect } from "next/navigation"; + +interface PageProps { + searchParams: Promise<{ + search?: string; + status?: string; + sort?: string; + page?: string; + per_page?: string; + }>; + params: Promise<{ riskId: string; locale: string }>; +} + +export default async function RiskPage({ searchParams, params }: PageProps) { + const session = await auth(); + const t = await getI18n(); + + const { + search, + status, + sort, + page = "1", + per_page = "5", + } = await searchParams; + + const { riskId } = await params; + const columnHeaders = await getServerColumnHeaders(); + const [column, order] = sort?.split(":") ?? []; + const hasFilters = !!(search || status); + const { tasks: loadedTasks, total } = await getTasks({ + riskId, + search, + status: status as RiskTaskStatus, + column, + order, + page: Number.parseInt(page), + per_page: Number.parseInt(per_page), + }); + + if (!session) { + redirect("/auth"); + } + + if (!session.user.organizationId || !riskId) { + redirect("/"); + } + + const risk = await getRisk(riskId, session.user.organizationId); + + if (!risk) { + redirect("/risk"); + } + + const users = await getUsers(session.user.organizationId); + + return ( +
+ + + + + +
+ {t("risk.tasks.title")} +
+
+
+ +
+ + {loadedTasks.length > 0 ? ( + + ) : hasFilters ? ( + + ) : ( + <> + + + + )} +
+
+
+ +
+ + +
+
+ ); +} + +const getRisk = unstable_cache( + async (riskId: string, organizationId: string) => { + const risk = await db.risk.findUnique({ + where: { + id: riskId, + organizationId: organizationId, + }, + include: { + owner: true, + }, + }); + + return risk; + }, + ["risk-cache"], +); + +const getTasks = unstable_cache( + async function tasks({ + riskId, + search, + status, + column, + order, + page = 1, + per_page = 10, + }: { + riskId: string; + search?: string; + status?: RiskTaskStatus; + column?: string; + order?: string; + page?: number; + per_page?: number; + }) { + const skip = (page - 1) * per_page; + + const [tasks, total] = await Promise.all([ + db.riskMitigationTask + .findMany({ + where: { + riskId, + AND: [ + search + ? { + OR: [ + { title: { contains: search, mode: "insensitive" } }, + { + description: { contains: search, mode: "insensitive" }, + }, + ], + } + : {}, + status ? { status } : {}, + ], + }, + orderBy: column + ? { + [column]: order === "asc" ? "asc" : "desc", + } + : { + createdAt: "desc", + }, + skip, + take: per_page, + include: { + owner: { + select: { + name: true, + image: true, + }, + }, + }, + }) + .then((tasks) => + tasks.map( + (task) => + ({ + ...task, + dueDate: task.dueDate?.toISOString() ?? "", + owner: { + name: task.owner?.name ?? "", + image: task.owner?.image ?? "", + }, + }) as RiskTaskType, + ), + ), + db.riskMitigationTask.count({ + where: { + riskId, + AND: [ + search + ? { + OR: [ + { title: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ], + } + : {}, + status ? { status } : {}, + ], + }, + }), + ]); + + return { tasks, total }; + }, + ["tasks-cache"], +); + +const getUsers = unstable_cache( + async (organizationId: string) => { + const users = await db.user.findMany({ + where: { organizationId: organizationId }, + }); + + return users; + }, + ["users-cache"], +); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("sub_pages.risk.risk_overview"), + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/actions/getTaskAttachments.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/actions/getTaskAttachments.ts new file mode 100644 index 0000000000..8da91dda97 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/actions/getTaskAttachments.ts @@ -0,0 +1,61 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { z } from "zod"; + +export const getTaskAttachments = authActionClient + .schema( + z.object({ + id: z.string(), + }), + ) + .metadata({ + name: "getTaskAttachments", + track: { + event: "get-task-attachments", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { id } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + const attachments = await db.taskAttachment.findMany({ + where: { + riskMitigationTaskId: id, + organizationId: user.organizationId, + }, + select: { + fileUrl: true, + fileKey: true, + }, + }); + + if (!attachments) { + return { + success: false, + error: "Task attachments not found", + }; + } + + return { + success: true, + data: attachments, + }; + } catch (error) { + console.error("Error fetching task attachments:", error); + return { + success: false, + error: "Failed to fetch task attachments", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/hooks/useTaskAttachments.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/hooks/useTaskAttachments.ts new file mode 100644 index 0000000000..9e773bf37d --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/hooks/useTaskAttachments.ts @@ -0,0 +1,29 @@ +"use client"; + +import useSWR from "swr"; +import { getTaskAttachments } from "../actions/getTaskAttachments"; + +interface UseTaskAttachmentProps { + id: string; +} + +async function fetchTaskAttachments({ id }: UseTaskAttachmentProps) { + const result = await getTaskAttachments({ id }); + + if (!result || "error" in result) { + throw new Error( + typeof result?.error === "string" + ? result.error + : "Failed to fetch task attachments", + ); + } + + return result.data; +} + +export function useTaskAttachments({ id }: UseTaskAttachmentProps) { + return useSWR(["task-attachments", id], () => fetchTaskAttachments({ id }), { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/page.tsx new file mode 100644 index 0000000000..532a3335a7 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/[taskId]/page.tsx @@ -0,0 +1,87 @@ +import { auth } from "@/auth"; +import { TaskComment } from "@/components/risks/tasks/task-comments"; +import { TaskOverview } from "@/components/risks/tasks/task-overview"; +import { TaskAttachments } from "@/components/risks/tasks/task-attachments"; +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"; + +interface PageProps { + params: Promise<{ riskId: string; taskId: string }>; +} + +export default async function RiskPage({ params }: PageProps) { + const session = await auth(); + const { riskId, taskId } = await params; + + if (!session) { + redirect("/auth"); + } + + if (!session.user.organizationId || !riskId) { + redirect("/"); + } + + const task = await getTask(riskId, taskId); + + if (!task) { + redirect("/risk"); + } + + const users = await getUsers(session.user.organizationId); + + return ( +
+ + + +
+ ); +} + +const getTask = unstable_cache( + async (riskId: string, taskId: string) => { + const task = await db.riskMitigationTask.findUnique({ + where: { + riskId: riskId, + id: taskId, + }, + include: { + owner: true, + TaskAttachment: true, + TaskComments: true, + }, + }); + + return task; + }, + ["risk-cache"], +); + +const getUsers = unstable_cache( + async (organizationId: string) => { + const users = await db.user.findMany({ + where: { organizationId: organizationId }, + }); + + return users; + }, + ["users-cache"], +); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("sub_pages.risk.tasks.task_overview"), + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/search-params.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/search-params.ts new file mode 100644 index 0000000000..55c0834e4e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/[riskId]/tasks/search-params.ts @@ -0,0 +1,14 @@ +import { + createSearchParamsCache, + parseAsInteger, + parseAsString, +} from "nuqs/server"; + +export const searchParamsCache = createSearchParamsCache({ + q: parseAsString, + page: parseAsInteger.withDefault(0), + start: parseAsString, + end: parseAsString, + status: parseAsString, + ownerId: parseAsString, +}); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/RiskRegisterTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/RiskRegisterTable.tsx new file mode 100644 index 0000000000..1b044a91fc --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/RiskRegisterTable.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { CreateRiskSheet } from "@/components/sheets/create-risk-sheet"; +import { DataTable } from "@/components/ui/data-table"; +import { useI18n } from "@/locales/client"; +import type { Departments, Risk, RiskStatus, User } from "@bubba/db/types"; +import { Plus } from "lucide-react"; +import { useQueryState } from "nuqs"; +import { useState } from "react"; +import { useOrganizationAdmins } from "../../evidence/[id]/hooks/useOrganizationAdmins"; +import { columns } from "./components/table/RiskRegisterColumns"; +import { RiskRegisterFilters } from "./components/table/RiskRegisterFilters"; +import { useRisks } from "./hooks/useRisks"; + +type RiskRegisterTableRow = Risk & { owner: User | null }; + +export const RiskRegisterTable = () => { + const t = useI18n(); + // State + const [search, setSearch] = useState(""); + const [open, setOpen] = useQueryState("create-risk-sheet"); + + const [page, setPage] = useQueryState("page", { + defaultValue: 1, + parse: Number.parseInt, + }); + const [pageSize, setPageSize] = useQueryState("pageSize", { + defaultValue: 10, + parse: Number, + }); + const [status, setStatus] = useQueryState("status", { + defaultValue: null, + parse: (value) => value as RiskStatus | null, + }); + const [department, setDepartment] = useQueryState( + "department", + { + defaultValue: null, + parse: (value) => value as Departments | null, + }, + ); + const [assigneeId, setAssigneeId] = useQueryState( + "assigneeId", + { + defaultValue: null, + parse: (value) => value, + }, + ); + + const { data, isLoading } = useRisks({ + search: search, + page: Number(page), + pageSize: Number(pageSize), + status, + department, + assigneeId, + }); + + const hasActiveFilters = Boolean(status || department || assigneeId); + + const handleClearFilters = () => { + setStatus(null); + setDepartment(null); + setAssigneeId(null); + setPage(1); + }; + + const departments: Departments[] = [ + "none", + "it", + "hr", + "admin", + "gov", + "itsm", + "qms", + ] as const; + + const { data: admins } = useOrganizationAdmins(); + + const filterCategories = RiskRegisterFilters({ + setPage: (newPage: number) => setPage(newPage), + departments: departments, + assignees: admins || [], + status, + setStatus, + department, + setDepartment, + assigneeId, + setAssigneeId, + }); + + return ( + <> + + columns={columns} + data={data} + isLoading={isLoading} + search={{ + value: search, + onChange: setSearch, + }} + pagination={{ + page: Number(page), + pageSize: Number(pageSize), + totalCount: data.length, + totalPages: Math.ceil(data.length / Number(pageSize)), + hasNextPage: Number(page) < Math.ceil(data.length / Number(pageSize)), + hasPreviousPage: Number(page) > 1, + }} + onPageChange={(newPage) => setPage(newPage)} + onPageSizeChange={(newPageSize) => setPageSize(newPageSize)} + filters={{ + categories: filterCategories, + hasActiveFilters, + onClearFilters: handleClearFilters, + activeFilterCount: [status, department, assigneeId].filter(Boolean) + .length, + }} + ctaButton={{ + label: t("risk.register.empty.create_risk"), + onClick: () => setOpen("true"), + icon: , + }} + /> + + + ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/actions/getRisks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/actions/getRisks.ts new file mode 100644 index 0000000000..a3bcad233c --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/actions/getRisks.ts @@ -0,0 +1,65 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { Departments, Prisma, RiskStatus } from "@bubba/db/types"; +import { z } from "zod"; + +export const getRisks = authActionClient + .schema( + z.object({ + search: z.string().optional(), + page: z.number().optional().default(1), + pageSize: z.number().optional().default(10), + status: z.nativeEnum(RiskStatus).nullable().optional(), + department: z.nativeEnum(Departments).nullable().optional(), + assigneeId: z.string().nullable().optional(), + }) + ) + .metadata({ + name: "get-risks", + track: { + event: "get-risks", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { search, page, pageSize, status, department, assigneeId } = + parsedInput; + const { user } = ctx; + + if (!user.organizationId) { + return { + success: false, + error: "Unauthorized", + }; + } + + const where = { + organizationId: user.organizationId, + ...(search && { + title: { + contains: search, + mode: Prisma.QueryMode.insensitive, + }, + }), + ...(status ? { status } : {}), + ...(department ? { department } : {}), + ...(assigneeId ? { ownerId: assigneeId } : {}), + }; + + const skip = (page - 1) * (pageSize ?? 10); + + const risks = await db.risk.findMany({ + where, + skip, + take: pageSize, + include: { + owner: true, + }, + }); + + return { + data: risks, + }; + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterColumns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterColumns.tsx new file mode 100644 index 0000000000..52fd8129b8 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterColumns.tsx @@ -0,0 +1,56 @@ +import type { Risk, User } from "@bubba/db/types"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@bubba/ui/badge"; +import Link from "next/link"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { Status } from "@/components/status"; + +export const columns: ColumnDef[] = [ + { + header: "Risk", + accessorKey: "title", + cell: ({ row }) => { + return ( + {row.original.title} + ); + }, + }, + { + header: "Status", + accessorKey: "status", + cell: ({ row }) => { + return ; + }, + }, + { + header: "Department", + accessorKey: "department", + cell: ({ row }) => { + return ( + + {row.original.department} + + ); + }, + }, + { + header: "Assignee", + accessorKey: "assignee", + cell: ({ row }) => { + return ( +
+ + + + {row.original.owner?.name?.charAt(0) || "?"} + + +

{row.original.owner?.name}

+
+ ); + }, + }, +]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterFilters.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterFilters.tsx new file mode 100644 index 0000000000..33a9ad3c22 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterFilters.tsx @@ -0,0 +1,72 @@ +import { Status } from "@/components/status"; +import { type Departments, RiskStatus } from "@bubba/db/types"; +import type { Admin } from "../../../../evidence/[id]/hooks/useOrganizationAdmins"; +import { AssigneeAvatar } from "../../../../evidence/list/components/table/components/AssigneeAvatar"; + +export const RiskRegisterFilters = ({ + setPage, + departments, + assignees, + status, + setStatus, + department, + setDepartment, + assigneeId, + setAssigneeId, +}: { + setPage: (page: number) => void; + departments: Departments[]; + assignees: Admin[] | undefined; + status: RiskStatus | null; + setStatus: (status: RiskStatus | null) => void; + department: Departments | null; + setDepartment: (department: Departments | null) => void; + assigneeId: string | null; + setAssigneeId: (assigneeId: string | null) => void; +}) => { + return [ + { + label: "Filter by Status", + items: Object.values(RiskStatus).map((filter) => ({ + label: filter + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + icon: , + value: filter, + checked: status === filter, + onChange: (checked: boolean) => { + setStatus(checked ? filter : null); + setPage(1); + }, + })), + }, + { + label: "Filter by Department", + items: departments.map((dept) => ({ + label: dept.replace(/_/g, " ").toUpperCase(), + value: dept, + checked: department === dept, + onChange: (checked: boolean) => { + setDepartment(checked ? dept : null); + setPage(1); + }, + })), + maxHeight: "150px", + }, + { + label: "Filter by Assignee", + items: (assignees || []).map((assignee) => ({ + label: assignee.name || "Unknown", + value: assignee.id, + checked: assigneeId === assignee.id, + onChange: (checked: boolean) => { + setAssigneeId(checked ? assignee.id : null); + setPage(1); + }, + icon: , + })), + maxHeight: "150px", + }, + ]; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/hooks/useRisks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/hooks/useRisks.ts new file mode 100644 index 0000000000..dadec88004 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/hooks/useRisks.ts @@ -0,0 +1,61 @@ +import type { Departments, RiskStatus } from "@bubba/db/types"; +import useSWR from "swr"; +import { getRisks } from "../actions/getRisks"; + +const fetchRisks = async (input: { + search?: string; + page?: number; + pageSize?: number; + status?: RiskStatus | null; + department?: Departments | null; + assigneeId?: string | null; +}) => { + const response = await getRisks(input); + + if (!response) { + throw new Error("Failed to fetch risks"); + } + + if (response.serverError) { + throw new Error(response.serverError); + } + + if (response.validationErrors) { + throw new Error( + response.validationErrors._errors?.join(", ") || + "Validation error occurred" + ); + } + + return response.data?.data; +}; + +export const useRisks = ({ + search = "", + page = 1, + pageSize = 10, + status, + department, + assigneeId, +}: { + search?: string; + page?: number; + pageSize?: number; + status?: RiskStatus | null; + department?: Departments | null; + assigneeId?: string | null; +}) => { + const { data, isLoading, error, mutate } = useSWR( + ["risks", search, page, pageSize, status, department, assigneeId], + () => + fetchRisks({ search, page, pageSize, status, department, assigneeId }), + { + revalidateOnFocus: true, + revalidateOnReconnect: true, + revalidateOnMount: true, + revalidateIfStale: true, + } + ); + + return { data: data || [], isLoading, error, mutate }; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/layout.tsx new file mode 100644 index 0000000000..71699d2dc1 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/layout.tsx @@ -0,0 +1,23 @@ +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(); + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/page.tsx new file mode 100644 index 0000000000..249725e8e6 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/page.tsx @@ -0,0 +1,22 @@ +import { RiskRegisterTable } from "./RiskRegisterTable"; +import type { Metadata } from "next"; +import { getI18n } from "@/locales/server"; +import { setStaticParamsLocale } from "next-international/server"; + +export default function RiskRegisterPage() { + return ; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("sub_pages.risk.register"), + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/search-params.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/search-params.ts new file mode 100644 index 0000000000..3f1579e77b --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/search-params.ts @@ -0,0 +1,15 @@ +import { + createSearchParamsCache, + parseAsInteger, + parseAsString, +} from "nuqs/server"; + +export const searchParamsCache = createSearchParamsCache({ + q: parseAsString, + page: parseAsInteger.withDefault(0), + start: parseAsString, + end: parseAsString, + status: parseAsString, + department: parseAsString, + ownerId: parseAsString, +}); From ea6d909516045672a62f63e7af4b773c9f68cade Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Wed, 19 Mar 2025 14:22:42 -0400 Subject: [PATCH 2/7] migrated all files here --- .../(dashboard)/vendors/(overview)/page.tsx | 5 +- .../components/create-risk-comment-form.tsx | 106 ++++++ .../vendors/components/create-risk-form.tsx | 306 ++++++++++++++++++ .../vendors/components/create-risk-sheet.tsx | 55 ++++ .../vendors/components/inherent-risk-form.tsx | 126 ++++++++ .../vendors/components/residual-risk-form.tsx | 132 ++++++++ .../vendors/components/risk-overview.tsx | 240 ++++++++++++++ .../task/create-task-comment-form.tsx | 108 +++++++ .../components/task/create-task-form.tsx | 250 ++++++++++++++ .../components/task/update-task-form.tsx | 197 +++++++++++ .../task/update-task-overview-form.tsx | 124 +++++++ .../vendors/components/update-risk-form.tsx | 126 ++++++++ 12 files changed, 1773 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-comment-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-sheet.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/inherent-risk-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/residual-risk-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/risk-overview.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-comment-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-overview-form.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/update-risk-form.tsx diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx index bd4d24bd77..9a6972eeb6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/(overview)/page.tsx @@ -30,13 +30,14 @@ export default async function RiskManagement({ return (
-
+ Coming Soon + {/*
-
+
*/}
); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-comment-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-comment-form.tsx new file mode 100644 index 0000000000..1ac4951376 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-comment-form.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { createRiskCommentAction } from "@/actions/risk/create-risk-comment"; +import { createRiskCommentSchema } from "@/actions/schema"; +import { useI18n } from "@/locales/client"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@bubba/ui/accordion"; +import { Button } from "@bubba/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@bubba/ui/form"; +import { Textarea } from "@bubba/ui/textarea"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowRightIcon } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useParams } from "next/navigation"; +import { useQueryState } from "nuqs"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { z } from "zod"; + +export function CreateRiskCommentForm() { + const t = useI18n(); + const [_, setCreateRiskCommentSheet] = useQueryState("risk-comment-sheet"); + const params = useParams<{ riskId: string }>(); + + const createRiskComment = useAction(createRiskCommentAction, { + onSuccess: () => { + toast.success(t("common.comments.success")); + setCreateRiskCommentSheet(null); + }, + onError: () => { + toast.error(t("common.comments.error")); + }, + }); + + const form = useForm>({ + resolver: zodResolver(createRiskCommentSchema), + defaultValues: { + content: "", + riskId: params.riskId, + }, + }); + + const onSubmit = (data: z.infer) => { + createRiskComment.execute(data); + }; + + return ( +
+ +
+
+ + + {t("common.comments.new")} + +
+ ( + + +