diff --git a/apps/app/languine.lock b/apps/app/languine.lock index 3de4e91fb1..14a8df881a 100644 --- a/apps/app/languine.lock +++ b/apps/app/languine.lock @@ -185,6 +185,8 @@ files: sub_pages.frameworks.overview: 04fb5df59766a9852e5dd2d0430ca27e sub_pages.tests.overview: 107f3983ed2f362df6195c163001fe7b sub_pages.tests.test_details: cb6255fb6a9fb2ec5de81a7eed81fa21 + sub_pages.vendors.overview: dfcc5efe778e6340336015dc02617922 + sub_pages.vendors.register: 122b14e8fa33bc9d4f389700439dfc71 auth.title: 4b33d501b99fef8f85f32b942fcd0b1f auth.description: 1fd4b045f6d46683c611e45b8de4bcf6 auth.options: 4dab36ac83853282fc0d7bae20c19e90 @@ -702,8 +704,9 @@ files: evidence.details.review_section: 659514b42799e5982d28793d9e003ae3 evidence.details.content: 393ae95538cd395eb8d1f7521f652b10 vendors.title: dfcc5efe778e6340336015dc02617922 + vendors.dashboard.title: 3b878279a04dc47d60932cb294d96259 vendors.register.title: 122b14e8fa33bc9d4f389700439dfc71 - vendors.dashboard.title: 003dd1a6f6efb2ac33fa8519c89dedcc + vendors.register.create_new: ef1c7d5e555d7fe288bf0bf16b88723d dashboard.risk_status: 6ce5e6fa832289748023cb773a4e89ac dashboard.risks_by_department: 0ef508d7e840bc5f85547c3b25e5d87b dashboard.vendor_status: 17029fbe5ad4d2af5feaf37bb4604225 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..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 @@ -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,67 @@ 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 ( +
+ Coming Soon + {/*
+ +
+ +
+ +
*/} +
+ ); } -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/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")} + +
+ ( + + +