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 (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-form.tsx
new file mode 100644
index 0000000000..acd015e90b
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-risk-form.tsx
@@ -0,0 +1,306 @@
+"use client";
+
+import { createRiskAction } from "@/actions/risk/create-risk-action";
+import { createRiskSchema } from "@/actions/schema";
+import { useOrganizationAdmins } from "@/app/[locale]/(app)/(dashboard)/evidence/[id]/hooks/useOrganizationAdmins";
+import { useRisks } from "@/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks";
+import { SelectUser } from "@/components/select-user";
+import { useI18n } from "@/locales/client";
+import type { RiskStatus } from "@bubba/db/types";
+import { Departments, RiskCategory } from "@bubba/db/types";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@bubba/ui/accordion";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@bubba/ui/form";
+import { Input } from "@bubba/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@bubba/ui/select";
+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 { useQueryState } from "nuqs";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+interface User {
+ id: string;
+ image?: string | null;
+ name: string | null;
+}
+
+export function CreateRisk() {
+ const t = useI18n();
+
+ // Get the same query parameters as the table
+ const [search] = useQueryState("search");
+ const [page] = useQueryState("page", {
+ defaultValue: 1,
+ parse: Number.parseInt,
+ });
+ const [pageSize] = useQueryState("pageSize", {
+ defaultValue: 10,
+ parse: Number,
+ });
+ const [status] = useQueryState("status", {
+ defaultValue: null,
+ parse: (value) => value as RiskStatus | null,
+ });
+ const [department] = useQueryState("department", {
+ defaultValue: null,
+ parse: (value) => value as Departments | null,
+ });
+ const [assigneeId] = useQueryState("assigneeId", {
+ defaultValue: null,
+ parse: (value) => value,
+ });
+
+ const { mutate: mutateRisks } = useRisks({
+ search: search || "",
+ page: Number(page),
+ pageSize: Number(pageSize),
+ status,
+ department,
+ assigneeId,
+ });
+
+ const { data: admins, isLoading: isLoadingAdmins } = useOrganizationAdmins();
+ const [_, setCreateRiskSheet] = useQueryState("create-risk-sheet");
+
+ const createRisk = useAction(createRiskAction, {
+ onSuccess: async () => {
+ toast.success(t("risk.form.create_risk_success"));
+ setCreateRiskSheet(null);
+ // Force invalidate and revalidate the risks list
+ await mutateRisks();
+ },
+ onError: () => {
+ toast.error(t("risk.form.create_risk_error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(createRiskSchema),
+ defaultValues: {
+ title: "",
+ description: "",
+ category: RiskCategory.operations,
+ department: Departments.admin,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ createRisk.execute(data);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-vendor-sheet.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-vendor-sheet.tsx
new file mode 100644
index 0000000000..45baecbc25
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/create-vendor-sheet.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useI18n } from "@/locales/client";
+import { Button } from "@bubba/ui/button";
+import { Drawer, DrawerContent, DrawerTitle } from "@bubba/ui/drawer";
+import { useMediaQuery } from "@bubba/ui/hooks";
+import { ScrollArea } from "@bubba/ui/scroll-area";
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@bubba/ui/sheet";
+import { X } from "lucide-react";
+import { useQueryState } from "nuqs";
+import { CreateRisk } from "./create-risk-form";
+
+export function CreateVendorSheet() {
+ const t = useI18n();
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+ const [open, setOpen] = useQueryState("createVendorSheet");
+ const isOpen = Boolean(open);
+
+ const handleOpenChange = (open: boolean) => {
+ setOpen(open ? "true" : null);
+ };
+
+ if (isDesktop) {
+ return (
+
+
+
+ {t("risk.create")}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {t("risk.create")}
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/inherent-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/inherent-risk-form.tsx
new file mode 100644
index 0000000000..049a15142b
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/inherent-risk-form.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { updateInherentRiskAction } from "@/actions/risk/update-inherent-risk-action";
+import { updateInherentRiskSchema } from "@/actions/schema";
+import { useI18n } from "@/locales/client";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@bubba/ui/form";
+import { Slider } from "@bubba/ui/slider";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useQueryState } from "nuqs";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+interface InherentRiskFormProps {
+ riskId: string;
+ initialProbability?: number;
+ initialImpact?: number;
+}
+
+export function InherentRiskForm({
+ riskId,
+ initialProbability,
+ initialImpact,
+}: InherentRiskFormProps) {
+ const [_, setOpen] = useQueryState("inherent-risk-sheet");
+ const t = useI18n();
+
+ const updateInherentRisk = useAction(updateInherentRiskAction, {
+ onSuccess: () => {
+ toast.success(t("risk.form.update_inherent_risk_success"));
+ setOpen(null);
+ },
+ onError: () => {
+ toast.error(t("risk.form.update_inherent_risk_error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(updateInherentRiskSchema),
+ defaultValues: {
+ id: riskId,
+ probability: initialProbability,
+ impact: initialImpact,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ updateInherentRisk.execute(data);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/residual-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/residual-risk-form.tsx
new file mode 100644
index 0000000000..94566da12e
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/residual-risk-form.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { updateResidualRiskAction } from "@/actions/risk/update-residual-risk-action";
+import { updateResidualRiskSchema } from "@/actions/schema";
+import { useI18n } from "@/locales/client";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@bubba/ui/form";
+import { Slider } from "@bubba/ui/slider";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useQueryState } from "nuqs";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+interface ResidualRiskFormProps {
+ riskId: string;
+ initialProbability: number;
+ initialImpact: number;
+ onSuccess?: () => void;
+}
+
+interface FormData {
+ probability: number;
+ impact: number;
+}
+
+export function ResidualRiskForm({
+ riskId,
+ initialProbability,
+ initialImpact,
+}: ResidualRiskFormProps) {
+ const t = useI18n();
+ const [_, setOpen] = useQueryState("residual-risk-sheet");
+
+ const form = useForm>({
+ resolver: zodResolver(updateResidualRiskSchema),
+ defaultValues: {
+ id: riskId,
+ probability: initialProbability ? initialProbability : 0,
+ impact: initialImpact ? initialImpact : 0,
+ },
+ });
+
+ const updateResidualRisk = useAction(updateResidualRiskAction, {
+ onSuccess: () => {
+ toast.success(t("risk.form.update_residual_risk_success"));
+ setOpen(null);
+ },
+ onError: () => {
+ toast.error(t("risk.form.update_residual_risk_error"));
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ updateResidualRisk.execute(data);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/risk-overview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/risk-overview.tsx
new file mode 100644
index 0000000000..e48665a389
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/risk-overview.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { updateRiskAction } from "@/actions/risk/update-risk-action";
+import { updateRiskSchema } from "@/actions/schema";
+import { SelectUser } from "@/components/select-user";
+import { STATUS_TYPES, Status, type StatusType } from "@/components/status";
+import { useI18n } from "@/locales/client";
+import {
+ Departments,
+ type Risk,
+ RiskCategory,
+ RiskStatus,
+ type User,
+} from "@bubba/db/types";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@bubba/ui/form";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@bubba/ui/select";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+export function UpdateRiskOverview({
+ risk,
+ users,
+}: {
+ risk: Risk;
+ users: User[];
+}) {
+ const t = useI18n();
+
+ const updateRisk = useAction(updateRiskAction, {
+ onSuccess: () => {
+ toast.success(t("risk.form.update_risk_success"));
+ },
+ onError: () => {
+ toast.error(t("risk.form.update_risk_error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(updateRiskSchema),
+ defaultValues: {
+ id: risk.id,
+ title: risk.title ?? "",
+ description: risk.description ?? "",
+ ownerId: risk.ownerId ?? undefined,
+ category: risk.category ?? RiskCategory.operations,
+ department: risk.department ?? Departments.admin,
+ status: risk.status ?? RiskStatus.open,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ updateRisk.execute({
+ id: data.id,
+ title: data.title,
+ description: data.description,
+ ownerId: data.ownerId,
+ category: data.category,
+ department: data.department,
+ status: data.status,
+ });
+ };
+
+ console.log(form.formState.defaultValues);
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-comment-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-comment-form.tsx
new file mode 100644
index 0000000000..4c63172f22
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-comment-form.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { createTaskCommentAction } from "@/actions/risk/task/create-task-comment";
+import { createTaskCommentSchema } 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 CreateTaskCommentForm() {
+ const t = useI18n();
+ const [_, setCreateTaskCommentSheet] = useQueryState("task-comment-sheet");
+ const params = useParams<{ riskId: string; taskId: string }>();
+
+ const createTaskComment = useAction(createTaskCommentAction, {
+ onSuccess: () => {
+ toast.success(t("common.comments.success"));
+ setCreateTaskCommentSheet(null);
+ },
+ onError: () => {
+ toast.error(t("common.comments.error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(createTaskCommentSchema),
+ defaultValues: {
+ content: "",
+ riskId: params.riskId,
+ taskId: params.taskId,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ createTaskComment.execute(data);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-form.tsx
new file mode 100644
index 0000000000..5d36bf1330
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/create-task-form.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { getOrganizationUsersAction } from "@/actions/organization/get-organization-users-action";
+import { createTaskAction } from "@/actions/risk/task/create-task-action";
+import { createTaskSchema } from "@/actions/schema";
+import { SelectUser } from "@/components/select-user";
+import { useI18n } from "@/locales/client";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@bubba/ui/accordion";
+import { Button } from "@bubba/ui/button";
+import { Calendar } from "@bubba/ui/calendar";
+import { cn } from "@bubba/ui/cn";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@bubba/ui/form";
+import { Input } from "@bubba/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectTrigger,
+ SelectValue,
+} from "@bubba/ui/select";
+import { Textarea } from "@bubba/ui/textarea";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { format } from "date-fns";
+import { CalendarIcon } from "lucide-react";
+import { ArrowRightIcon } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useParams } from "next/navigation";
+import { useQueryState } from "nuqs";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+interface User {
+ id: string;
+ image?: string | null;
+ name: string | null;
+}
+
+export function CreateTaskForm() {
+ const t = useI18n();
+
+ const [users, setUsers] = useState([]);
+ const [isLoadingUsers, setIsLoadingUsers] = useState(true);
+ const [_, setCreateTaskSheet] = useQueryState("create-task-sheet");
+ const params = useParams<{ riskId: string }>();
+
+ useEffect(() => {
+ async function loadUsers() {
+ const result = await getOrganizationUsersAction();
+ if (result?.data?.success && result?.data?.data) {
+ setUsers(result.data.data);
+ }
+ setIsLoadingUsers(false);
+ }
+
+ loadUsers();
+ }, []);
+
+ const createTask = useAction(createTaskAction, {
+ onSuccess: () => {
+ toast.success(t("risk.tasks.form.success"));
+ setCreateTaskSheet(null);
+ },
+ onError: () => {
+ toast.error(t("risk.tasks.form.error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(createTaskSchema),
+ defaultValues: {
+ title: "",
+ description: "",
+ dueDate: new Date(),
+ ownerId: "",
+ riskId: params.riskId,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ createTask.execute(data);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-form.tsx
new file mode 100644
index 0000000000..3b5d9d8518
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-form.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { updateTaskAction } from "@/actions/risk/task/update-task-action";
+import { updateTaskSchema } from "@/actions/schema";
+import { SelectUser } from "@/components/select-user";
+import { STATUS_TYPES, Status, type StatusType } from "@/components/status";
+import { useI18n } from "@/locales/client";
+import { type RiskMitigationTask, RiskTaskStatus, type User } from "@bubba/db/types";
+import { Button } from "@bubba/ui/button";
+import { Calendar } from "@bubba/ui/calendar";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@bubba/ui/form";
+import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@bubba/ui/select";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { format } from "date-fns";
+import { CalendarIcon, Loader2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import React from "react";
+
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+import { cn } from "../../../../../../../packages/ui/src/utils";
+
+export function UpdateTaskForm({
+ task,
+ users,
+}: {
+ task: RiskMitigationTask;
+ users: User[];
+}) {
+ const t = useI18n();
+
+ const updateTask = useAction(updateTaskAction, {
+ onSuccess: () => {
+ toast.success("Task updated successfully");
+ },
+ onError: () => {
+ toast.error("Something went wrong, please try again.");
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(updateTaskSchema),
+ defaultValues: {
+ id: task.id,
+ dueDate: task.dueDate ? new Date(task.dueDate) : undefined,
+ ownerId: task.ownerId ?? undefined,
+ status: task.status ?? RiskTaskStatus.open,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ updateTask.execute({
+ id: data.id,
+ dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
+ ownerId: data.ownerId,
+ status: data.status as RiskTaskStatus,
+ });
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-overview-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-overview-form.tsx
new file mode 100644
index 0000000000..d0b01477a7
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/task/update-task-overview-form.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { updateTaskAction } from "@/actions/risk/task/update-task-action";
+import { updateTaskSchema } from "@/actions/schema";
+import { useI18n } from "@/locales/client";
+import type { RiskMitigationTask } from "@bubba/db/types";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@bubba/ui/form";
+import { Input } from "@bubba/ui/input";
+import { Textarea } from "@bubba/ui/textarea";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useQueryState } from "nuqs";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+export function UpdateTaskOverviewForm({
+ task,
+}: {
+ task: RiskMitigationTask;
+}) {
+ const t = useI18n();
+ const [open, setOpen] = useQueryState("task-update-overview-sheet");
+
+ const updateTask = useAction(updateTaskAction, {
+ onSuccess: () => {
+ toast.success(t("risk.form.update_risk_success"));
+ setOpen(null);
+ },
+ onError: () => {
+ toast.error(t("risk.form.update_risk_error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(updateTaskSchema),
+ defaultValues: {
+ id: task.id,
+ title: task.title,
+ description: task.description,
+ dueDate: task.dueDate ? new Date(task.dueDate) : undefined,
+ status: task.status,
+ ownerId: task.ownerId ?? undefined,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ updateTask.execute({
+ id: data.id,
+ title: data.title,
+ description: data.description,
+ dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
+ status: data.status,
+ ownerId: data.ownerId,
+ });
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/update-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/update-risk-form.tsx
new file mode 100644
index 0000000000..5df1ff615f
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/components/update-risk-form.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { updateRiskAction } from "@/actions/risk/update-risk-action";
+import { updateRiskSchema } from "@/actions/schema";
+import { useI18n } from "@/locales/client";
+import { Departments, type Risk } from "@bubba/db/types";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@bubba/ui/form";
+import { Input } from "@bubba/ui/input";
+import { Textarea } from "@bubba/ui/textarea";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useQueryState } from "nuqs";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+export function UpdateRiskForm({
+ risk,
+}: {
+ risk: Risk;
+}) {
+ const t = useI18n();
+ const [open, setOpen] = useQueryState("risk-overview-sheet");
+
+ const updateRisk = useAction(updateRiskAction, {
+ onSuccess: () => {
+ toast.success(t("risk.form.update_risk_success"));
+ setOpen(null);
+ },
+ onError: () => {
+ toast.error(t("risk.form.update_risk_error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(updateRiskSchema),
+ defaultValues: {
+ id: risk.id,
+ title: risk.title,
+ description: risk.description,
+ category: risk.category,
+ department: risk.department ?? Departments.admin,
+ status: risk.status,
+ ownerId: risk.ownerId ?? undefined,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ updateRisk.execute({
+ id: data.id,
+ title: data.title,
+ description: data.description,
+ category: data.category,
+ department: data.department,
+ status: data.status,
+ ownerId: data.ownerId,
+ });
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/VendorRegisterTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/VendorRegisterTable.tsx
new file mode 100644
index 0000000000..544bc98864
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/VendorRegisterTable.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+
+import { DataTable } from "@/components/ui/data-table";
+import { useI18n } from "@/locales/client";
+import type { Departments, RiskStatus, User, Vendor } 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 { CreateVendorSheet } from "../components/create-vendor-sheet";
+import { columns } from "./components/table/RiskRegisterColumns";
+
+type VendorRegisterTableRow = Vendor & { owner: User | null };
+
+export const VendorRegisterTable = ({ data }: { data: VendorRegisterTableRow[] }) => {
+ const t = useI18n();
+ // State
+ const [search, setSearch] = useState("");
+ const [open, setOpen] = useQueryState("createVendorSheet");
+
+ 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 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("vendors.register.create_new"),
+ onClick: () => setOpen("true"),
+ icon: ,
+ }}
+ />
+
+ >
+ );
+};
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..78efc21925
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/components/table/RiskRegisterColumns.tsx
@@ -0,0 +1,56 @@
+import type { Vendor, 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: "Vendor",
+ accessorKey: "name",
+ cell: ({ row }) => {
+ return (
+ {row.original.name}
+ );
+ },
+ },
+ // {
+ // 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/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..59ec00175c
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/vendors/register/page.tsx
@@ -0,0 +1,78 @@
+import { auth } from "@/auth";
+import { getI18n } from "@/locales/server";
+import { db } from "@bubba/db";
+import type { Metadata } from "next";
+import { setStaticParamsLocale } from "next-international/server";
+import { redirect } from "next/navigation";
+import { VendorRegisterTable } from "./VendorRegisterTable";
+import { Departments, VendorStatus } from "@bubba/db/types";
+import { z } from "zod";
+
+export default async function Page({
+ params,
+ searchParams,
+}: {
+ params: { locale: string },
+ searchParams: Promise<{
+ createVendorSheet?: string;
+ page?: string;
+ pageSize?: string;
+ status?: string;
+ department?: string;
+ assigneeId?: string;
+ }>
+}) {
+ const searchParamsSchema = z.object({
+ createVendorSheet: z.string().optional(),
+ page: z.string().regex(/^\d+$/).transform(Number).optional(),
+ pageSize: z.string().regex(/^\d+$/).transform(Number).optional(),
+ status: z.nativeEnum(VendorStatus).optional(),
+ department: z.nativeEnum(Departments).optional(),
+ assigneeId: z.string().uuid().optional()
+ });
+
+ const result = searchParamsSchema.safeParse(await searchParams);
+
+ if (!result.success) {
+ console.error('Invalid search params:', result.error);
+ redirect('/vendors/register');
+ }
+
+ const { createVendorSheet, page, pageSize, status, department, assigneeId } = result.data;
+
+ const session = await auth();
+
+ if (!session?.user?.organizationId) {
+ redirect("/onboarding");
+ }
+
+ const vendors = await db.vendor.findMany({
+ where: {
+ organizationId: session.user.organizationId,
+ ...(status && { status: status }),
+ ...(department && { department: department }),
+ ...(assigneeId && { ownerId: assigneeId })
+ },
+ include: {
+ owner: true
+ },
+ skip: page ? (Number(page) - 1) * Number(pageSize || 10) : 0,
+ take: Number(pageSize || 10)
+ });
+
+ 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.vendors.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,
+});
diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts
index 634029a0b8..fdf3c4ee43 100644
--- a/apps/app/src/locales/en.ts
+++ b/apps/app/src/locales/en.ts
@@ -271,6 +271,10 @@ export default {
overview: "Cloud Tests",
test_details: "Test Details",
},
+ vendors: {
+ overview: "Vendors",
+ register: "Vendor Register",
+ },
},
auth: {
title: "Automate SOC 2, ISO 27001 and GDPR compliance with AI.",
@@ -1089,11 +1093,12 @@ export default {
},
vendors: {
title: "Vendors",
+ dashboard: {
+ title: "Overview",
+ },
register: {
title: "Vendor Register",
- },
- dashboard: {
- title: "Vendor Overview",
+ create_new: "Create Vendor",
},
},
dashboard: {
diff --git a/apps/app/src/locales/es.ts b/apps/app/src/locales/es.ts
index 4509f14656..68427309f3 100644
--- a/apps/app/src/locales/es.ts
+++ b/apps/app/src/locales/es.ts
@@ -984,6 +984,10 @@ export default {
tests: {
overview: "Pruebas en la Nube",
test_details: "Detalles de la Prueba"
+ },
+ vendors: {
+ overview: "Proveedores",
+ register: "Registro de Proveedores"
}
},
editor: {
@@ -1159,10 +1163,11 @@ export default {
vendors: {
title: "Proveedores",
register: {
- title: "Registro de proveedores"
+ title: "Registro de proveedores",
+ create_new: "Crear Proveedor"
},
dashboard: {
- title: "Resumen de proveedores"
+ title: "Descripción general"
}
},
dashboard: {
diff --git a/apps/app/src/locales/fr.ts b/apps/app/src/locales/fr.ts
index 46b09923b3..c1a04c018c 100644
--- a/apps/app/src/locales/fr.ts
+++ b/apps/app/src/locales/fr.ts
@@ -984,6 +984,10 @@ export default {
tests: {
overview: "Tests en nuage",
test_details: "Détails du test"
+ },
+ vendors: {
+ overview: "Fournisseurs",
+ register: "Registre des Fournisseurs"
}
},
editor: {
@@ -1159,10 +1163,11 @@ export default {
vendors: {
title: "Fournisseurs",
register: {
- title: "Enregistrement des fournisseurs"
+ title: "Inscription du fournisseur",
+ create_new: "Créer un Fournisseur"
},
dashboard: {
- title: "Aperçu des fournisseurs"
+ title: "Aperçu"
}
},
dashboard: {
diff --git a/apps/app/src/locales/no.ts b/apps/app/src/locales/no.ts
index b8553f1535..169a94ab4a 100644
--- a/apps/app/src/locales/no.ts
+++ b/apps/app/src/locales/no.ts
@@ -984,6 +984,10 @@ export default {
tests: {
overview: "Skytester",
test_details: "Testdetaljer"
+ },
+ vendors: {
+ overview: "Leverandører",
+ register: "Leverandørregister"
}
},
editor: {
@@ -1159,10 +1163,11 @@ export default {
vendors: {
title: "Leverandører",
register: {
- title: "Leverandørregister"
+ title: "Leverandørregister",
+ create_new: "Opprett leverandør"
},
dashboard: {
- title: "Oversikt over leverandører"
+ title: "Oversikt"
}
},
dashboard: {
diff --git a/apps/app/src/locales/pt.ts b/apps/app/src/locales/pt.ts
index 03faa0e261..faea2b535c 100644
--- a/apps/app/src/locales/pt.ts
+++ b/apps/app/src/locales/pt.ts
@@ -984,6 +984,10 @@ export default {
tests: {
overview: "Testes em Nuvem",
test_details: "Detalhes do Teste"
+ },
+ vendors: {
+ overview: "Fornecedores",
+ register: "Registro de Fornecedor"
}
},
editor: {
@@ -1130,10 +1134,11 @@ export default {
vendors: {
title: "Fornecedores",
register: {
- title: "Registro de Fornecedor"
+ title: "Registro de Fornecedor",
+ create_new: "Criar Fornecedor"
},
dashboard: {
- title: "Visão Geral do Fornecedor"
+ title: "Visão Geral"
}
},
dashboard: {