From f635e1f982231d694ece4eb215744fc4ecd0f28c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 18:03:34 -0400 Subject: [PATCH 1/5] refactor: replace ownerId with assigneeId across vendor and task schemas, actions, and components for improved clarity and consistency --- .../actions/risk/task/update-task-action.ts | 104 ++--- apps/app/src/actions/schema.ts | 4 +- .../[orgId]/components/SelectAssignee.tsx | 1 + .../employees/all/types/search-params.ts | 2 +- .../(app)/(dashboard)/[orgId]/page.tsx | 3 - .../[orgId]/risk/(overview)/layout.tsx | 3 + .../[orgId]/risk/[riskId]/page.tsx | 9 +- .../risk/[riskId]/tasks/search-params.ts | 2 +- .../[orgId]/vendors/(overview)/layout.tsx | 30 +- .../actions/task/create-task-action.ts | 80 ++-- .../actions/update-vendor-action.ts | 94 ++--- .../actions/update-vendor-residual-risk.ts | 47 +-- .../components/inherent-risk-vendor-chart.tsx | 339 ++++++++++------ .../[vendorId]/components/inherent-risk.tsx | 19 +- .../components/residual-risk-vendor-chart.tsx | 332 ++++++++++------ .../[vendorId]/components/residual-risk.tsx | 47 ++- .../secondary-fields/secondary-fields.tsx | 10 +- .../update-secondary-fields-form.tsx | 42 +- .../tasks/create-vendor-task-form.tsx | 30 +- .../title-and-description.tsx | 2 +- .../update-title-and-description-form.tsx | 4 +- .../forms/risks/inherent-risk-form.tsx | 25 +- .../forms/risks/residual-risk-form.tsx | 25 +- .../[orgId]/vendors/[vendorId]/page.tsx | 42 +- .../[vendorId]/tasks/[taskId]/page.tsx | 3 + .../vendors/actions/create-vendor-action.ts | 70 ++-- .../vendors/components/create-vendor-form.tsx | 43 +- .../components/create-vendor-sheet.tsx | 11 +- .../components/VendorRegisterColumns.tsx | 23 +- .../components/VendorRegisterTable.tsx | 78 +--- .../[orgId]/vendors/register/page.tsx | 40 +- apps/app/src/app/api/v1/risks/[id]/route.ts | 348 +++++++++-------- apps/app/src/app/api/v1/risks/route.ts | 368 +++++++++--------- .../forms/risks/task/update-task-form.tsx | 6 +- .../risks/task/update-task-overview-form.tsx | 4 +- .../forms/risks/update-risk-form.tsx | 4 +- .../policies/charts/policies-by-assignee.tsx | 18 +- .../tables/policies/filter-toolbar.tsx | 14 +- .../tables/risk-register/columns.tsx | 19 +- .../risk-register/data-table-header.tsx | 8 +- .../tables/risk-register/data-table.tsx | 4 +- .../tables/risk-register/filter-toolbar.tsx | 18 +- .../tables/risk-register/server-columns.tsx | 14 +- .../tables/risk-tasks/data-table-header.tsx | 8 +- .../tables/risk-tasks/data-table.tsx | 5 +- .../tables/risk-tasks/filter-toolbar.tsx | 2 +- .../tables/risk-tasks/server-columns.tsx | 12 +- apps/app/src/data/tools/risks.ts | 144 +++---- apps/app/src/locales/core/common.ts | 2 +- .../migration.sql | 22 ++ .../migration.sql | 2 + .../migration.sql | 8 + packages/db/prisma/schema/auth.prisma | 2 +- packages/db/prisma/schema/task.prisma | 4 +- packages/db/prisma/schema/vendor.prisma | 16 +- packages/docs/api-reference/v1/risks.mdx | 28 +- 56 files changed, 1500 insertions(+), 1144 deletions(-) create mode 100644 packages/db/prisma/migrations/20250403205655_vendor_assignee/migration.sql create mode 100644 packages/db/prisma/migrations/20250403205840_oops_optional/migration.sql create mode 100644 packages/db/prisma/migrations/20250403210049_task_assignee_optional_too/migration.sql diff --git a/apps/app/src/actions/risk/task/update-task-action.ts b/apps/app/src/actions/risk/task/update-task-action.ts index 1a4a6874b1..2cae22847a 100644 --- a/apps/app/src/actions/risk/task/update-task-action.ts +++ b/apps/app/src/actions/risk/task/update-task-action.ts @@ -9,55 +9,55 @@ import { authActionClient } from "../../safe-action"; import { updateTaskSchema } from "../../schema"; export const updateTaskAction = authActionClient - .schema(updateTaskSchema) - .metadata({ - name: "update-task", - track: { - event: "update-task", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, dueDate, status, ownerId, title, description } = parsedInput; - const { session } = ctx; - - if (!session.activeOrganizationId) { - throw new Error("Invalid user input"); - } - - try { - const task = await db.task.findUnique({ - where: { - id: id, - }, - }); - - if (!task) { - throw new Error("Task not found"); - } - - await db.task.update({ - where: { - id: id, - organizationId: session.activeOrganizationId, - }, - data: { - dueDate: dueDate, - status: status as TaskStatus, - userId: ownerId, - title: title, - description: description, - }, - }); - - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}/tasks/${id}`); - revalidateTag("risks"); - - return { success: true }; - } catch (error) { - console.error(error); - return { success: false }; - } - }); + .schema(updateTaskSchema) + .metadata({ + name: "update-task", + track: { + event: "update-task", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, dueDate, status, assigneeId, title, description } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + throw new Error("Invalid user input"); + } + + try { + const task = await db.task.findUnique({ + where: { + id: id, + }, + }); + + if (!task) { + throw new Error("Task not found"); + } + + await db.task.update({ + where: { + id: id, + organizationId: session.activeOrganizationId, + }, + data: { + dueDate: dueDate, + status: status as TaskStatus, + assigneeId, + title: title, + description: description, + }, + }); + + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}/tasks/${id}`); + revalidateTag("risks"); + + return { success: true }; + } catch (error) { + console.error(error); + return { success: false }; + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index f64088f130..2c5c03c10d 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -141,7 +141,7 @@ export const createTaskSchema = z.object({ message: "Task description is required", }), dueDate: z.date().optional(), - ownerId: z + assigneeId: z .string({ required_error: "You must assign an owner to the task", }) @@ -160,7 +160,7 @@ export const updateTaskSchema = z.object({ status: z.nativeEnum(TaskStatus, { required_error: "Task status is required", }), - ownerId: z.string({ + assigneeId: z.string({ required_error: "You must assign an owner to the task", }), }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx index ac7727cad7..be5e7d586a 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx @@ -8,6 +8,7 @@ import { import { UserIcon } from "lucide-react"; import { Member, User } from "@comp/db/types"; import { useState, useEffect } from "react"; +import { db } from "@comp/db"; interface SelectAssigneeProps { assigneeId: string | null; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/types/search-params.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/types/search-params.ts index 3f1579e77b..b51788dfc6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/types/search-params.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/types/search-params.ts @@ -11,5 +11,5 @@ export const searchParamsCache = createSearchParamsCache({ end: parseAsString, status: parseAsString, department: parseAsString, - ownerId: parseAsString, + assigneeId: parseAsString, }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/page.tsx index 6a9a3d337a..f3bb21065c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/page.tsx @@ -9,9 +9,6 @@ export default async function DashboardPage() { const organizationId = session?.session.activeOrganizationId; - console.log("session", session); - console.log("organizationId", organizationId); - if (!organizationId) { redirect("/"); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/(overview)/layout.tsx index 45a2d74ee3..34fff6454a 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/(overview)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/(overview)/layout.tsx @@ -97,6 +97,9 @@ const getAssignees = cache(async () => { where: { organizationId: activeOrganizationId, isActive: true, + role: { + notIn: ["employee"], + }, }, include: { user: true, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/page.tsx index e69099a7ea..e3bc5187bc 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/page.tsx @@ -199,10 +199,10 @@ async function getTasks({ title: task.title, status: task.status, dueDate: task.dueDate.toISOString(), - assigneeId: task.assigneeId, + assigneeId: task.assigneeId ?? "", assignee: { - name: task.assignee.user.name, - image: task.assignee.user.image ?? "", + name: task.assignee?.user.name ?? "", + image: task.assignee?.user.image ?? "", }, }), ), @@ -242,6 +242,9 @@ async function getAssignees() { const assignees = await db.member.findMany({ where: { organizationId: session.session.activeOrganizationId, + role: { + notIn: ["employee"], + }, }, include: { user: true, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/tasks/search-params.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/tasks/search-params.ts index 55c0834e4e..1f1d026e22 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/tasks/search-params.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/risk/[riskId]/tasks/search-params.ts @@ -10,5 +10,5 @@ export const searchParamsCache = createSearchParamsCache({ start: parseAsString, end: parseAsString, status: parseAsString, - ownerId: parseAsString, + assigneeId: parseAsString, }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/layout.tsx index f8cf8215f8..23dc53daef 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/layout.tsx @@ -21,8 +21,8 @@ export default async function Layout({ }); const orgId = activeOrganizationId; - const overview = await getVendorOverview(); + const assignees = await getAssignees(); if (overview?.vendors === 0) { return ( @@ -51,7 +51,7 @@ export default async function Layout({ }, ]} /> - + @@ -77,6 +77,32 @@ export default async function Layout({ ); } +const getAssignees = cache(async () => { + const { + session: { activeOrganizationId }, + } = await getServersideSession({ + headers: await headers(), + }); + + if (!activeOrganizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId: activeOrganizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; +}); + const getVendorOverview = cache(async () => { const { session: { activeOrganizationId }, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/task/create-task-action.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/task/create-task-action.ts index 30ddb68c44..9bc58a6411 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/task/create-task-action.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/task/create-task-action.ts @@ -8,47 +8,47 @@ import { authActionClient } from "@/actions/safe-action"; import { createVendorTaskSchema } from "../schema"; export const createVendorTaskAction = authActionClient - .schema(createVendorTaskSchema) - .metadata({ - name: "create-vendor-task", - track: { - event: "create-vendor-task", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { vendorId, title, description, dueDate, ownerId } = parsedInput; - const { - session: { activeOrganizationId }, - user, - } = ctx; + .schema(createVendorTaskSchema) + .metadata({ + name: "create-vendor-task", + track: { + event: "create-vendor-task", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { vendorId, title, description, dueDate, assigneeId } = parsedInput; + const { + session: { activeOrganizationId }, + user, + } = ctx; - if (!user.id || !activeOrganizationId) { - throw new Error("Invalid user input"); - } + if (!user.id || !activeOrganizationId) { + throw new Error("Invalid user input"); + } - try { - await db.task.create({ - data: { - title, - description, - dueDate, - userId: user.id, - organizationId: activeOrganizationId, - relatedId: vendorId, - relatedType: "vendor", - }, - }); + try { + await db.task.create({ + data: { + title, + description, + dueDate, + assigneeId, + organizationId: activeOrganizationId, + relatedId: vendorId, + relatedType: "vendor", + }, + }); - revalidatePath(`/${activeOrganizationId}/vendor/${vendorId}`); - revalidateTag(`vendor_${activeOrganizationId}`); + revalidatePath(`/${activeOrganizationId}/vendor/${vendorId}`); + revalidateTag(`vendor_${activeOrganizationId}`); - return { - success: true, - }; - } catch (error) { - return { - success: false, - }; - } - }); + return { + success: true, + }; + } catch (error) { + return { + success: false, + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-action.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-action.ts index a674185729..2f5b718415 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-action.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-action.ts @@ -8,50 +8,50 @@ import { authActionClient } from "@/actions/safe-action"; import { updateVendorSchema } from "./schema"; export const updateVendorAction = authActionClient - .schema(updateVendorSchema) - .metadata({ - name: "update-vendor", - track: { - event: "update-vendor", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, name, description, category, ownerId, status } = parsedInput; - const { session } = ctx; - - if (!session.activeOrganizationId) { - throw new Error("Invalid user input"); - } - - try { - await db.vendor.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - name, - description, - ownerId, - category, - status, - }, - }); - - revalidatePath(`/${session.activeOrganizationId}/vendors`); - revalidatePath(`/${session.activeOrganizationId}/vendors/register`); - revalidatePath(`/${session.activeOrganizationId}/vendors/${id}`); - revalidateTag("vendors"); - - return { - success: true, - }; - } catch (error) { - console.error("Error updating vendor:", error); - - return { - success: false, - }; - } - }); + .schema(updateVendorSchema) + .metadata({ + name: "update-vendor", + track: { + event: "update-vendor", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, name, description, category, assigneeId, status } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + throw new Error("Invalid user input"); + } + + try { + await db.vendor.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + name, + description, + assigneeId, + category, + status, + }, + }); + + revalidatePath(`/${session.activeOrganizationId}/vendors`); + revalidatePath(`/${session.activeOrganizationId}/vendors/register`); + revalidatePath(`/${session.activeOrganizationId}/vendors/${id}`); + revalidateTag("vendors"); + + return { + success: true, + }; + } catch (error) { + console.error("Error updating vendor:", error); + + return { + success: false, + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts index 08f0e4a05d..160e5fcc8b 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts @@ -4,33 +4,36 @@ import { appErrors } from "@/lib/errors"; import type { ActionResponse } from "@/types/actions"; import { db } from "@comp/db"; import { createSafeActionClient } from "next-safe-action"; +import { revalidatePath } from "next/cache"; import { z } from "zod"; import { Impact, Likelihood } from "@prisma/client"; const schema = z.object({ - vendorId: z.string(), - residualProbability: z.nativeEnum(Likelihood), - residualImpact: z.nativeEnum(Impact), + vendorId: z.string(), + residualProbability: z.nativeEnum(Likelihood), + residualImpact: z.nativeEnum(Impact), }); export const updateVendorResidualRisk = createSafeActionClient() - .schema(schema) - .action(async ({ parsedInput }): Promise => { - try { - await db.vendor.update({ - where: { id: parsedInput.vendorId }, - data: { - residualProbability: parsedInput.residualProbability, - residualImpact: parsedInput.residualImpact, - }, - }); + .schema(schema) + .action(async ({ parsedInput }): Promise => { + try { + await db.vendor.update({ + where: { id: parsedInput.vendorId }, + data: { + residualProbability: parsedInput.residualProbability, + residualImpact: parsedInput.residualImpact, + }, + }); - return { success: true }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : appErrors.UNEXPECTED_ERROR, - }; - } - }); + revalidatePath(`/vendors/${parsedInput.vendorId}`); + + return { success: true }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : appErrors.UNEXPECTED_ERROR, + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx index e1e44b6878..e3e57946a3 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx @@ -11,140 +11,245 @@ import { CardHeader, CardTitle, } from "@comp/ui/card"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@comp/ui/chart"; import { PencilIcon } from "lucide-react"; import { useQueryState } from "nuqs"; -import { - PolarAngleAxis, - PolarGrid, - PolarRadiusAxis, - Radar, - RadarChart, -} from "recharts"; import { InherentRiskSheet } from "./inherent-risk"; -import { useCallback } from "react"; interface InherentRiskChartProps { vendor: Vendor; } +// Map enum values to numeric scores (1-5) +const LIKELIHOOD_SCORES: Record = { + very_unlikely: 1, + unlikely: 2, + possible: 3, + likely: 4, + very_likely: 5, +}; + +const IMPACT_SCORES: Record = { + insignificant: 1, + minor: 2, + moderate: 3, + major: 4, + severe: 5, +}; + +// Risk level colors +const RISK_COLORS = { + low: "#22c55e", // green + medium: "#f59e0b", // amber + high: "#f97316", // orange + critical: "#ef4444", // red +}; + export function InherentRiskVendorChart({ vendor }: InherentRiskChartProps) { const t = useI18n(); const [open, setOpen] = useQueryState("inherent-risk-sheet"); - // Create a stable callback function for onSuccess - const handleSuccess = useCallback(() => { - console.log("Closing inherent risk sheet"); - setOpen(null); - }, [setOpen]); - - // Convert the enum values to numeric scores for display - const getRiskScore = (probability: Likelihood, impact: Impact) => { - const probabilityScores: Record = { - very_unlikely: 10, - unlikely: 30, - possible: 50, - likely: 70, - very_likely: 90, - }; - - const impactScores: Record = { - insignificant: 10, - minor: 30, - moderate: 50, - major: 70, - severe: 90, - }; - - return (probabilityScores[probability] * impactScores[impact]) / 100; - }; - - const inherentRiskScore = getRiskScore( - vendor.inherentProbability, - vendor.inherentImpact, - ); + // Calculate risk score from probability and impact + const riskScore = + LIKELIHOOD_SCORES[vendor.inherentProbability] * + IMPACT_SCORES[vendor.inherentImpact]; - const data = [ - { - metric: t("vendors.risks.inherent_risk"), - value: inherentRiskScore, - fullMark: 100, - }, - ]; + // Determine risk level + let riskLevel = "low"; + if (riskScore > 16) riskLevel = "critical"; + else if (riskScore > 9) riskLevel = "high"; + else if (riskScore > 4) riskLevel = "medium"; + + // Get color based on risk level + const riskColor = RISK_COLORS[riskLevel as keyof typeof RISK_COLORS]; + + // Get translated risk level using known safe keys + let riskLevelText: string; + switch (riskLevel) { + case "low": + riskLevelText = t("vendors.risks.low"); + break; + case "medium": + riskLevelText = t("vendors.risks.medium"); + break; + case "high": + riskLevelText = t("vendors.risks.high"); + break; + case "critical": + riskLevelText = "Critical"; + break; + default: + riskLevelText = "Unknown"; + } + + // Generate the 5x5 matrix + const matrix = [] as { + x: number; + y: number; + color: string; + isActive: boolean; + }[]; + + for (let impactScore = 5; impactScore >= 1; impactScore--) { + for (let likelihoodScore = 1; likelihoodScore <= 5; likelihoodScore++) { + const cellScore = likelihoodScore * impactScore; + let cellColor = RISK_COLORS.low; + + if (cellScore > 16) cellColor = RISK_COLORS.critical; + else if (cellScore > 9) cellColor = RISK_COLORS.high; + else if (cellScore > 4) cellColor = RISK_COLORS.medium; - const chartConfig = { - risk: { - label: t("vendors.risks.inherent_risk"), - theme: { - light: "#ef4444", - dark: "#dc2626", - }, - }, - }; + matrix.push({ + x: likelihoodScore, + y: impactScore, + color: cellColor, + isActive: + likelihoodScore === LIKELIHOOD_SCORES[vendor.inherentProbability] && + impactScore === IMPACT_SCORES[vendor.inherentImpact], + }); + } + } + + const yAxisLabels = [ + "V.Likely", + "Likely", + "Possible", + "Unlikely", + "V.Unlikely", + ]; + const xAxisLabels = ["Insig", "Minor", "Mod", "Major", "Severe"]; return ( - - -
-
- -
- {t("vendors.risks.inherent_risk")} - + <> + + +
+
+ +
+ {t("vendors.risks.inherent_risk")} + +
+
+ + {t("vendors.risks.update_inherent_risk_description")} + +
+
+
+ +
+ {/* 5x5 Risk Matrix */} +
+ {/* Y-axis label */} +
+ Probability
- - - {t("vendors.risks.update_inherent_risk_description")} - + + {/* Main grid container */} +
+
+ {/* Grid rows */} + {[...Array(5)].map((_, rowIndex) => ( +
+ {/* Grid cells for this row */} + {[...Array(5)].map((_, colIndex) => { + // Find the corresponding cell in our matrix data + const cell = matrix.find( + (m) => m.x === colIndex + 1 && m.y === 5 - rowIndex, + ); + + return ( +
+ {cell?.isActive && ( +
+
+
+ )} +
+ ); + })} +
+ ))} + + {/* Y-axis labels - positioned outside the grid */} + {yAxisLabels.map((label, i) => ( +
+ {label} +
+ ))} + + {/* X-axis labels */} + {xAxisLabels.map((label, i) => ( +
+ {label} +
+ ))} + + {/* X-axis title */} +
+ Impact +
+
+
+
+
+ + {/* Risk level display */} +
+ + {riskLevelText} + + ({riskScore}/25)
-
- - - - - - - - - } - /> - - - - -
+ + + + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk.tsx index ffe6fac184..b7a7e91287 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk.tsx @@ -15,7 +15,7 @@ import { import { X } from "lucide-react"; import { InherentRiskForm } from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form"; import { Impact, Likelihood } from "@prisma/client"; -import { useState } from "react"; +import { useQueryState } from "nuqs"; export function InherentRiskSheet({ vendorId, @@ -30,18 +30,22 @@ export function InherentRiskSheet({ }) { const t = useI18n(); const isDesktop = useMediaQuery("(min-width: 768px)"); - const [isOpen, setIsOpen] = useState(true); + const [open, setOpen] = useQueryState("inherent-risk-sheet"); + const isOpen = open === "true"; - const handleClose = () => setIsOpen(false); + const handleClose = () => setOpen(null); const handleFormSuccess = () => { - setIsOpen(false); + setOpen(null); if (onSuccess) onSuccess(); }; if (isDesktop) { return ( - + setOpen(value ? "true" : null)} + >
@@ -74,7 +78,10 @@ export function InherentRiskSheet({ } return ( - + setOpen(value ? "true" : null)} + > diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx index 1961fa16f1..252aa28c8a 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx @@ -2,6 +2,7 @@ import { useI18n } from "@/locales/client"; import type { Vendor } from "@comp/db/types"; +import { Impact, Likelihood } from "@prisma/client"; import { Button } from "@comp/ui/button"; import { Card, @@ -10,138 +11,241 @@ import { CardHeader, CardTitle, } from "@comp/ui/card"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@comp/ui/chart"; import { PencilIcon } from "lucide-react"; import { useQueryState } from "nuqs"; -import { - PolarAngleAxis, - PolarGrid, - PolarRadiusAxis, - Radar, - RadarChart, -} from "recharts"; import { ResidualRiskSheet } from "./residual-risk"; -import { useCallback } from "react"; interface ResidualRiskChartProps { vendor: Vendor; } +// Map enum values to numeric scores (1-5) +const LIKELIHOOD_SCORES: Record = { + very_unlikely: 1, + unlikely: 2, + possible: 3, + likely: 4, + very_likely: 5, +}; + +const IMPACT_SCORES: Record = { + insignificant: 1, + minor: 2, + moderate: 3, + major: 4, + severe: 5, +}; + +// Risk level colors +const RISK_COLORS = { + low: "#22c55e", // green + medium: "#f59e0b", // amber + high: "#f97316", // orange + critical: "#ef4444", // red +}; + export function ResidualRiskVendorChart({ vendor }: ResidualRiskChartProps) { const t = useI18n(); const [open, setOpen] = useQueryState("residual-risk-sheet"); - // Log when the sheet open state changes for debugging - console.log("Residual risk sheet query state:", { open }); - - // Log the vendor data to check the structure and values - console.log("Vendor residual risk data:", { - id: vendor.id, - residualRisk: vendor.residualProbability, - }); - - // Create a stable callback function for onSuccess - const handleSuccess = useCallback(() => { - console.log("Closing residual risk sheet"); - setOpen(null); - }, [setOpen]); - - // Convert the enum values to numeric scores for display - const getRiskScore = (risk: string) => { - switch (risk) { - case "low": - return 30; - case "medium": - return 60; - case "high": - return 90; - default: - return 0; - } - }; + // Calculate risk score from probability and impact + const riskScore = + LIKELIHOOD_SCORES[vendor.residualProbability] * + IMPACT_SCORES[vendor.residualImpact]; - const residualRiskScore = getRiskScore(vendor.residualProbability); + // Determine risk level + let riskLevel = "low"; + if (riskScore > 16) riskLevel = "critical"; + else if (riskScore > 9) riskLevel = "high"; + else if (riskScore > 4) riskLevel = "medium"; - const data = [ - { - metric: t("vendors.risks.residual_risk"), - value: residualRiskScore, - fullMark: 100, - }, - ]; + // Get color based on risk level + const riskColor = RISK_COLORS[riskLevel as keyof typeof RISK_COLORS]; + + // Get translated risk level using known safe keys + let riskLevelText: string; + switch (riskLevel) { + case "low": + riskLevelText = t("vendors.risks.low"); + break; + case "medium": + riskLevelText = t("vendors.risks.medium"); + break; + case "high": + riskLevelText = t("vendors.risks.high"); + break; + case "critical": + riskLevelText = "Critical"; + break; + default: + riskLevelText = "Unknown"; + } + + // Generate the 5x5 matrix + const matrix = [] as { + x: number; + y: number; + color: string; + isActive: boolean; + }[]; - const chartConfig = { - risk: { - label: t("vendors.risks.residual_risk"), - theme: { - light: "#ef4444", - dark: "#dc2626", - }, - }, - }; + for (let impactScore = 5; impactScore >= 1; impactScore--) { + for (let likelihoodScore = 1; likelihoodScore <= 5; likelihoodScore++) { + const cellScore = likelihoodScore * impactScore; + let cellColor = RISK_COLORS.low; + + if (cellScore > 16) cellColor = RISK_COLORS.critical; + else if (cellScore > 9) cellColor = RISK_COLORS.high; + else if (cellScore > 4) cellColor = RISK_COLORS.medium; + + matrix.push({ + x: likelihoodScore, + y: impactScore, + color: cellColor, + isActive: + likelihoodScore === LIKELIHOOD_SCORES[vendor.residualProbability] && + impactScore === IMPACT_SCORES[vendor.residualImpact], + }); + } + } + + const yAxisLabels = [ + "V.Likely", + "Likely", + "Possible", + "Unlikely", + "V.Unlikely", + ]; + const xAxisLabels = ["Insig", "Minor", "Mod", "Major", "Severe"]; return ( - - -
-
- -
- {t("vendors.risks.residual_risk")} - + <> + + +
+
+ +
+ {t("vendors.risks.residual_risk")} + +
+
+ + {t("vendors.risks.update_residual_risk_description")} + +
+
+
+ +
+ {/* 5x5 Risk Matrix */} +
+ {/* Y-axis label */} +
+ Probability
- - - {t("vendors.risks.update_residual_risk_description")} - + + {/* Main grid container */} +
+
+ {/* Grid rows */} + {[...Array(5)].map((_, rowIndex) => ( +
+ {/* Grid cells for this row */} + {[...Array(5)].map((_, colIndex) => { + // Find the corresponding cell in our matrix data + const cell = matrix.find( + (m) => m.x === colIndex + 1 && m.y === 5 - rowIndex, + ); + + return ( +
+ {cell?.isActive && ( +
+
+
+ )} +
+ ); + })} +
+ ))} + + {/* Y-axis labels - positioned outside the grid */} + {yAxisLabels.map((label, i) => ( +
+ {label} +
+ ))} + + {/* X-axis labels */} + {xAxisLabels.map((label, i) => ( +
+ {label} +
+ ))} + + {/* X-axis title */} +
+ Impact +
+
+
+
+
+ + {/* Risk level display */} +
+ + {riskLevelText} + + ({riskScore}/25)
-
- - - - - - - - - } - /> - - - - -
+ + + + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk.tsx index 8be4641f8b..4fe6157dbc 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk.tsx @@ -15,7 +15,8 @@ import { import { X } from "lucide-react"; import { ResidualRiskForm } from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form"; import type { Vendor } from "@comp/db/types"; -import { useState } from "react"; +import { useQueryState } from "nuqs"; +import { useRouter, useSearchParams } from "next/navigation"; export function ResidualRiskSheet({ vendorId, @@ -27,19 +28,50 @@ export function ResidualRiskSheet({ onSuccess?: () => void; }) { const t = useI18n(); + const router = useRouter(); + const searchParams = useSearchParams(); const isDesktop = useMediaQuery("(min-width: 768px)"); - const [isOpen, setIsOpen] = useState(true); + const [open, setOpen] = useQueryState("residual-risk-sheet"); + const isOpen = open === "true"; - const handleClose = () => setIsOpen(false); + const handleClose = () => { + setOpen(null); + + // Create new URLSearchParams without the residual-risk-sheet parameter + const params = new URLSearchParams(searchParams); + params.delete("residual-risk-sheet"); + + // Create the new URL path with the updated query parameters + const newPath = + window.location.pathname + + (params.toString() ? `?${params.toString()}` : ""); + + // Update the URL without refreshing the page + router.replace(newPath); + }; const handleFormSuccess = () => { - setIsOpen(false); + setOpen(null); + + // Remove query params on success + const params = new URLSearchParams(searchParams); + params.delete("residual-risk-sheet"); + + const newPath = + window.location.pathname + + (params.toString() ? `?${params.toString()}` : ""); + + router.replace(newPath); + if (onSuccess) onSuccess(); }; if (isDesktop) { return ( - + (value ? setOpen("true") : handleClose())} + >
@@ -72,7 +104,10 @@ export function ResidualRiskSheet({ } return ( - + (value ? setOpen("true") : handleClose())} + > diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx index 39e1a5779a..2547557a14 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx @@ -1,16 +1,16 @@ "use client"; import { useI18n } from "@/locales/client"; -import type { User, Vendor } from "@comp/db/types"; +import type { Member, User, Vendor } from "@comp/db/types"; import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { UpdateSecondaryFieldsForm } from "./update-secondary-fields-form"; export function SecondaryFields({ vendor, - users, + assignees, }: { - vendor: Vendor & { owner: User | null }; - users: User[]; + vendor: Vendor & { assignee: { user: User | null } | null }; + assignees: (Member & { user: User })[]; }) { const t = useI18n(); @@ -25,7 +25,7 @@ export function SecondaryFields({ - +
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx index 2af58c6d9c..4afcc3bcbd 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx @@ -1,9 +1,8 @@ "use client"; -import { SelectUser } from "@/components/select-user"; import { VENDOR_STATUS_TYPES, VendorStatus } from "@/components/vendor-status"; import { useI18n } from "@/locales/client"; -import { VendorCategory, type User, type Vendor } from "@comp/db/types"; +import { Member, VendorCategory, type User, type Vendor } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { Form, @@ -26,15 +25,16 @@ import { useAction } from "next-safe-action/hooks"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import type { z } from "zod"; -import { updateVendorAction } from "../../actions/update-vendor-action"; +import { SelectAssignee } from "../../../../components/SelectAssignee"; import { updateVendorSchema } from "../../actions/schema"; +import { updateVendorAction } from "../../actions/update-vendor-action"; export function UpdateSecondaryFieldsForm({ vendor, - users, + assignees, }: { vendor: Vendor; - users: User[]; + assignees: (Member & { user: User })[]; }) { const t = useI18n(); @@ -53,7 +53,7 @@ export function UpdateSecondaryFieldsForm({ id: vendor.id, name: vendor.name, description: vendor.description, - ownerId: vendor.ownerId ?? undefined, + assigneeId: vendor.assigneeId, category: vendor.category, status: vendor.status, }, @@ -64,7 +64,7 @@ export function UpdateSecondaryFieldsForm({ id: data.id, name: data.name, description: data.description, - ownerId: data.ownerId, + assigneeId: data.assigneeId, category: data.category, status: data.status, }); @@ -76,30 +76,18 @@ export function UpdateSecondaryFieldsForm({
( {t("common.assignee.label")} - + diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx index c164942e9f..3c5113b649 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx @@ -41,6 +41,7 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import type { z } from "zod"; +import { SelectAssignee } from "../../../../components/SelectAssignee"; interface User { id: string; @@ -86,7 +87,7 @@ export function CreateVendorTaskForm() { title: "", description: "", dueDate: new Date(), - ownerId: "", + assigneeId: "", vendorId: params.vendorId, }, }); @@ -198,29 +199,18 @@ export function CreateVendorTaskForm() { ( {t("common.assignee.label")} - + diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/title-and-description.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/title-and-description.tsx index be9f3c2ca8..c83833f2f7 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/title-and-description.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/title-and-description.tsx @@ -12,7 +12,7 @@ import { UpdateTitleAndDescriptionSheet } from "./update-title-and-description-s export function TitleAndDescription({ vendor, }: { - vendor: Vendor & { owner: User | null }; + vendor: Vendor & { assignee: { user: User | null } | null }; }) { const t = useI18n(); const [_, setOpen] = useQueryState("vendor-overview-sheet"); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-form.tsx index 081586dbb2..3cda25b3bf 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-form.tsx @@ -50,7 +50,7 @@ export function UpdateTitleAndDescriptionForm({ description: vendor.description, category: vendor.category, status: vendor.status, - ownerId: vendor.ownerId ?? undefined, + assigneeId: vendor.assigneeId ?? undefined, }, }); @@ -62,7 +62,7 @@ export function UpdateTitleAndDescriptionForm({ description: data.description, category: data.category, status: data.status, - ownerId: data.ownerId, + assigneeId: data.assigneeId, }); }; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx index b5b81381cb..5194c00184 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx @@ -25,6 +25,7 @@ import { z } from "zod"; import { updateVendorInherentRisk } from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-inherent-risk"; import type { ActionResponse } from "@/types/actions"; import { Impact, Likelihood } from "@prisma/client"; +import { useQueryState } from "nuqs"; const formSchema = z.object({ inherentProbability: z.nativeEnum(Likelihood), @@ -49,6 +50,7 @@ export function InherentRiskForm({ const t = useI18n(); const { toast } = useToast(); const router = useRouter(); + const [_, setOpen] = useQueryState("inherent-risk-sheet"); const form = useForm({ resolver: zodResolver(formSchema), @@ -59,6 +61,7 @@ export function InherentRiskForm({ }); async function onSubmit(values: FormValues) { + console.log("Form submitted with values:", values); try { const response = await updateVendorInherentRisk({ vendorId, @@ -66,12 +69,14 @@ export function InherentRiskForm({ inherentImpact: values.inherentImpact, }); + console.log("Server response:", response); + const result = response as unknown as ActionResponse; - if (!result.success) { + if (!result?.success) { toast({ title: t("common.error"), - description: result.error || t("common.unexpected_error"), + description: result?.error || t("common.unexpected_error"), variant: "destructive", }); return; @@ -82,10 +87,14 @@ export function InherentRiskForm({ description: t("vendors.risks.inherent_risk_updated"), }); + // Close the sheet by setting the query parameter to null + setOpen(null); + if (onSuccess) onSuccess(); router.refresh(); } catch (error) { + console.error("Error submitting form:", error); toast({ title: t("common.error"), description: t("common.unexpected_error"), @@ -103,7 +112,11 @@ export function InherentRiskForm({ render={({ field }) => ( {t("vendors.risks.inherent_probability")} - ( {t("vendors.risks.inherent_impact")} - ({ resolver: zodResolver(formSchema), @@ -59,6 +61,7 @@ export function ResidualRiskForm({ }); async function onSubmit(values: FormValues) { + console.log("Form submitted with values:", values); try { const response = await updateVendorResidualRisk({ vendorId, @@ -66,12 +69,14 @@ export function ResidualRiskForm({ residualImpact: values.residualImpact, }); + console.log("Server response:", response); + const result = response as unknown as ActionResponse; - if (!result.success) { + if (!result?.success) { toast({ title: t("common.error"), - description: result.error || t("common.unexpected_error"), + description: result?.error || t("common.unexpected_error"), variant: "destructive", }); return; @@ -82,10 +87,14 @@ export function ResidualRiskForm({ description: t("vendors.risks.residual_risk_updated"), }); + // Close the sheet by setting the query parameter to null + setOpen(null); + if (onSuccess) onSuccess(); router.refresh(); } catch (error) { + console.error("Error submitting form:", error); toast({ title: t("common.error"), description: t("common.unexpected_error"), @@ -103,7 +112,11 @@ export function ResidualRiskForm({ render={({ field }) => ( {t("vendors.risks.residual_probability")} - ( {t("vendors.risks.residual_impact")} - - - + + {/*
@@ -99,8 +99,8 @@ export default async function VendorPage({ searchParams, params }: PageProps) { )}
-
-
+ */} +
@@ -123,14 +123,18 @@ async function getVendor(vendorId: string) { organizationId: session.session.activeOrganizationId, }, include: { - owner: true, + assignee: { + include: { + user: true, + }, + }, }, }); return vendor; } -async function getUsers() { +async function getAssignees() { const session = await auth.api.getSession({ headers: await headers(), }); @@ -139,14 +143,19 @@ async function getUsers() { return []; } - const members = await db.member.findMany({ - where: { organizationId: session.session.activeOrganizationId }, + const assignees = await db.member.findMany({ + where: { + organizationId: session.session.activeOrganizationId, + role: { + notIn: ["employee"], + }, + }, include: { user: true, }, }); - return members.map((member) => member.user); + return assignees; } async function getTasks({ @@ -205,10 +214,9 @@ async function getTasks({ skip, take: per_page, include: { - user: { - select: { - name: true, - image: true, + assignee: { + include: { + user: true, }, }, }, @@ -217,9 +225,9 @@ async function getTasks({ tasks.map((task) => ({ ...task, dueDate: task.dueDate ?? new Date(), - user: { - name: task.user?.name ?? "", - image: task.user?.image ?? "", + assignee: { + name: task.assignee?.user.name ?? "", + image: task.assignee?.user.image ?? "", }, })), ), diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx index e2a2b5327d..069c7cef60 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx @@ -53,6 +53,9 @@ export default async function TaskPage({ params }: PageProps) { const assignees = await db.member.findMany({ where: { organizationId: orgId, + role: { + notIn: ["employee"], + }, }, include: { user: true, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts index ef6520ffdd..11223d602b 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts @@ -10,45 +10,45 @@ import { db } from "@comp/db"; import { headers } from "next/headers"; const schema = z.object({ - name: z.string().min(1, "Name is required"), - website: z.string().url("Must be a valid URL").optional(), - description: z.string().optional(), - category: z.nativeEnum(VendorCategory), - status: z.nativeEnum(VendorStatus).default(VendorStatus.not_assessed), - ownerId: z.string().optional(), + name: z.string().min(1, "Name is required"), + website: z.string().url("Must be a valid URL").optional(), + description: z.string().optional(), + category: z.nativeEnum(VendorCategory), + status: z.nativeEnum(VendorStatus).default(VendorStatus.not_assessed), + assigneeId: z.string().optional(), }); export const createVendorAction = createSafeActionClient() - .schema(schema) - .action(async (input): Promise> => { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + .schema(schema) + .action(async (input): Promise> => { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.session?.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!session?.session?.activeOrganizationId) { + throw new Error("Unauthorized"); + } - const vendor = await db.vendor.create({ - data: { - name: input.parsedInput.name, - description: input.parsedInput.description || "", - category: input.parsedInput.category, - status: input.parsedInput.status, - ownerId: input.parsedInput.ownerId, - organizationId: session.session.activeOrganizationId, - }, - }); + const vendor = await db.vendor.create({ + data: { + name: input.parsedInput.name, + description: input.parsedInput.description || "", + category: input.parsedInput.category, + status: input.parsedInput.status, + assigneeId: input.parsedInput.assigneeId, + organizationId: session.session.activeOrganizationId, + }, + }); - revalidatePath(`/${session.session.activeOrganizationId}/vendors`); + revalidatePath(`/${session.session.activeOrganizationId}/vendors`); - return { success: true, data: vendor }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to create vendor", - }; - } - }); + return { success: true, data: vendor }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to create vendor", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx index 1d56205602..e9d4814a10 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx @@ -1,9 +1,8 @@ "use client"; -import { useOrganizationAdmins } from "@/app/[locale]/(app)/(dashboard)/[orgId]/hooks/useOrganizationAdmins"; -import { SelectUser } from "@/components/select-user"; import { useI18n } from "@/locales/client"; -import { VendorCategory, VendorStatus } from "@comp/db/types"; +import { Member, useSession } from "@comp/auth"; +import { User, VendorCategory, VendorStatus } from "@comp/db/types"; import { Accordion, AccordionContent, @@ -30,13 +29,13 @@ import { import { Textarea } from "@comp/ui/textarea"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRightIcon } from "lucide-react"; -import { useSession } from "@comp/auth"; import { useAction } from "next-safe-action/hooks"; import { useRouter } from "next/navigation"; import { useQueryState } from "nuqs"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { SelectAssignee } from "../../components/SelectAssignee"; import { createVendorAction } from "../actions/create-vendor-action"; const createVendorSchema = z.object({ @@ -45,10 +44,14 @@ const createVendorSchema = z.object({ description: z.string().optional(), category: z.nativeEnum(VendorCategory), status: z.nativeEnum(VendorStatus).default(VendorStatus.not_assessed), - ownerId: z.string().optional(), + assigneeId: z.string().optional(), }); -export function CreateVendorForm() { +export function CreateVendorForm({ + assignees, +}: { + assignees: (Member & { user: User })[]; +}) { const t = useI18n(); const session = useSession(); @@ -77,8 +80,6 @@ export function CreateVendorForm() { const router = useRouter(); - const { data: admins, isLoading: isLoadingAdmins } = useOrganizationAdmins(); - const createVendor = useAction(createVendorAction, { onSuccess: async (data) => { const organizationId = session.data?.session.activeOrganizationId; @@ -284,29 +285,17 @@ export function CreateVendorForm() { /> ( {t("common.assignee.label")} - + diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-sheet.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-sheet.tsx index e18cc21c0c..8ae6f08b85 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-sheet.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-sheet.tsx @@ -9,8 +9,13 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@comp/ui/sheet"; import { X } from "lucide-react"; import { useQueryState } from "nuqs"; import { CreateVendorForm } from "./create-vendor-form"; +import { Member, User } from "@comp/db/types"; -export function CreateVendorSheet() { +export function CreateVendorSheet({ + assignees, +}: { + assignees: (Member & { user: User })[]; +}) { const t = useI18n(); const isDesktop = useMediaQuery("(min-width: 768px)"); const [open, setOpen] = useQueryState("createVendorSheet"); @@ -37,7 +42,7 @@ export function CreateVendorSheet() { - + @@ -48,7 +53,7 @@ export function CreateVendorSheet() { - + ); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterColumns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterColumns.tsx index e099164065..14f6277aa8 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterColumns.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterColumns.tsx @@ -4,6 +4,7 @@ import { Badge } from "@comp/ui/badge"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; import type { VendorRegisterTableRow } from "./VendorRegisterTable"; +import { UserIcon } from "lucide-react"; export const columns: ColumnDef[] = [ { @@ -41,18 +42,32 @@ export const columns: ColumnDef[] = [ header: "Assignee", accessorKey: "assignee", cell: ({ row }) => { + // Handle null assignee + if (!row.original.assignee) { + return ( +
+
+ +
+

None

+
+ ); + } + return (
- {row.original.owner?.name?.charAt(0) || "?"} + {row.original.assignee.user.name?.charAt(0) || "?"} -

{row.original.owner?.name}

+

+ {row.original.assignee.user.name} +

); }, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterTable.tsx index e2681eafa7..7741d3ba34 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterTable.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/components/VendorRegisterTable.tsx @@ -2,19 +2,26 @@ import { DataTable } from "@/components/ui/data-table"; import { useI18n } from "@/locales/client"; -import type { Departments, RiskStatus, User, Vendor } from "@comp/db/types"; +import type { Member, User, Vendor } from "@comp/db/types"; import { Plus } from "lucide-react"; import { useQueryState } from "nuqs"; import { useState } from "react"; -import { useOrganizationAdmins } from "@/app/[locale]/(app)/(dashboard)/[orgId]/hooks/useOrganizationAdmins"; import { CreateVendorSheet } from "../../components/create-vendor-sheet"; import { columns } from "./VendorRegisterColumns"; -export type VendorRegisterTableRow = Vendor & { owner: User | null }; +export type VendorRegisterTableRow = Vendor & { + assignee: { + user: User; + } | null; +}; export const VendorRegisterTable = ({ data, -}: { data: VendorRegisterTableRow[] }) => { + assignees, +}: { + data: VendorRegisterTableRow[]; + assignees: (Member & { user: User })[]; +}) => { const t = useI18n(); // State const [search, setSearch] = useState(""); @@ -28,64 +35,12 @@ export const VendorRegisterTable = ({ 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 = VendorRegisterFilters({ - // setPage: (newPage: number) => setPage(newPage), - // departments: departments, - // assignees: admins || [], - // status, - // setStatus, - // department, - // setDepartment, - // assigneeId, - // setAssigneeId, - // }); return ( <> - + 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)/[orgId]/vendors/register/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/page.tsx index f9ca547393..4af3020c36 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/register/page.tsx @@ -8,6 +8,8 @@ import { VendorRegisterTable } from "./components/VendorRegisterTable"; import { Departments, VendorStatus } from "@comp/db/types"; import { z } from "zod"; import { headers } from "next/headers"; +import { getServersideSession } from "@/lib/get-session"; +import { cache } from "react"; export default async function Page({ searchParams, @@ -53,16 +55,22 @@ export default async function Page({ organizationId: session.session.activeOrganizationId, ...(status && { status: status }), ...(department && { department: department }), - ...(assigneeId && { ownerId: assigneeId }), + ...(assigneeId && { assigneeId: assigneeId }), }, include: { - owner: true, + assignee: { + select: { + user: true, + }, + }, }, skip: page ? (Number(page) - 1) * Number(pageSize || 10) : 0, take: Number(pageSize || 10), }); - return ; + const assignees = await getAssignees(); + + return ; } export async function generateMetadata({ @@ -78,3 +86,29 @@ export async function generateMetadata({ title: t("vendors.register.title"), }; } + +const getAssignees = cache(async () => { + const { + session: { activeOrganizationId }, + } = await getServersideSession({ + headers: await headers(), + }); + + if (!activeOrganizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId: activeOrganizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; +}); diff --git a/apps/app/src/app/api/v1/risks/[id]/route.ts b/apps/app/src/app/api/v1/risks/[id]/route.ts index 8893544be2..4a85c8e349 100644 --- a/apps/app/src/app/api/v1/risks/[id]/route.ts +++ b/apps/app/src/app/api/v1/risks/[id]/route.ts @@ -2,12 +2,12 @@ import { db } from "@comp/db"; import { NextResponse, type NextRequest } from "next/server"; import { getOrganizationFromApiKey } from "@/lib/api-key"; import type { - RiskTreatmentType, - RiskStatus, - RiskCategory, - Departments, - Likelihood, - Impact, + RiskTreatmentType, + RiskStatus, + RiskCategory, + Departments, + Likelihood, + Impact, } from "@comp/db/types"; // Configure this route to use Node.js runtime instead of Edge @@ -31,113 +31,127 @@ export const runtime = "nodejs"; * - 500: { success: false, error: "Failed to fetch risk" } */ export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, + request: NextRequest, + { params }: { params: Promise<{ id: string }> } ) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); - - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } - - try { - const riskId = (await params).id; - - // Fetch the risk - const risk = await db.risk.findFirst({ - where: { - id: riskId, - organizationId: organizationId!, - }, - select: { - id: true, - title: true, - description: true, - category: true, - department: true, - status: true, - likelihood: true, - impact: true, - residualLikelihood: true, - residualImpact: true, - createdAt: true, - updatedAt: true, - ownerId: true, - treatmentStrategy: true, - treatmentStrategyDescription: true, - owner: { - select: { - id: true, - name: true, - email: true, - }, - }, - }, - }); - - // If risk not found, return 404 - if (!risk) { - return NextResponse.json( - { - success: false, - error: "Risk not found", - }, - { status: 404 }, - ); - } - - // Fetch tasks for this risk - const tasks = await db.task.findMany({ - where: { - relatedId: riskId, - relatedType: "risk", - organizationId: organizationId!, - }, - select: { - id: true, - title: true, - description: true, - status: true, - dueDate: true, - createdAt: true, - updatedAt: true, - userId: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - - // Format dates for JSON response - const formattedRisk = { - ...risk, - createdAt: risk.createdAt.toISOString(), - updatedAt: risk.updatedAt.toISOString(), - tasks: tasks.map((task) => ({ - ...task, - createdAt: task.createdAt.toISOString(), - updatedAt: task.updatedAt.toISOString(), - dueDate: task.dueDate.toISOString(), - })), - }; - - return NextResponse.json({ - success: true, - data: formattedRisk, - }); - } catch (error) { - console.error("Error fetching risk:", error); - return NextResponse.json( - { - success: false, - error: "Failed to fetch risk", - }, - { status: 500 }, - ); - } + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + const riskId = (await params).id; + + // Fetch the risk + const risk = await db.risk.findFirst({ + where: { + id: riskId, + organizationId: organizationId!, + }, + select: { + id: true, + title: true, + description: true, + category: true, + department: true, + status: true, + likelihood: true, + impact: true, + residualLikelihood: true, + residualImpact: true, + createdAt: true, + updatedAt: true, + assigneeId: true, + treatmentStrategy: true, + treatmentStrategyDescription: true, + assignee: { + select: { + id: true, + user: { + select: { + name: true, + email: true, + }, + }, + }, + }, + }, + }); + + // If risk not found, return 404 + if (!risk) { + return NextResponse.json( + { + success: false, + error: "Risk not found", + }, + { status: 404 } + ); + } + + // Fetch tasks for this risk + const tasks = await db.task.findMany({ + where: { + relatedId: riskId, + relatedType: "risk", + organizationId: organizationId!, + }, + select: { + id: true, + title: true, + description: true, + status: true, + dueDate: true, + createdAt: true, + updatedAt: true, + assigneeId: true, + assignee: { + select: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Format dates for JSON response + const formattedRisk = { + ...risk, + createdAt: risk.createdAt.toISOString(), + updatedAt: risk.updatedAt.toISOString(), + tasks: tasks.map((task) => ({ + ...task, + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + dueDate: task.dueDate.toISOString(), + })), + }; + + return NextResponse.json({ + success: true, + data: formattedRisk, + }); + } catch (error) { + console.error("Error fetching risk:", error); + return NextResponse.json( + { + success: false, + error: "Failed to fetch risk", + }, + { status: 500 } + ); + } } /** @@ -158,60 +172,60 @@ export async function GET( * - 500: { success: false, error: string } */ export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, + request: NextRequest, + { params }: { params: Promise<{ id: string }> } ) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); - - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } - - try { - const riskId = (await params).id; - - // Check if the risk exists and belongs to the organization - const existingRisk = await db.risk.findFirst({ - where: { - id: riskId, - organizationId: organizationId!, - }, - }); - - if (!existingRisk) { - return NextResponse.json( - { - success: false, - error: "Risk not found", - }, - { status: 404 }, - ); - } - - // Delete the risk - await db.risk.delete({ - where: { - id: riskId, - }, - }); - - return NextResponse.json({ - success: true, - data: { - message: "Risk deleted successfully", - }, - }); - } catch (error) { - console.error("Error deleting risk:", error); - return NextResponse.json( - { - success: false, - error: "Failed to delete risk", - }, - { status: 500 }, - ); - } + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + const riskId = (await params).id; + + // Check if the risk exists and belongs to the organization + const existingRisk = await db.risk.findFirst({ + where: { + id: riskId, + organizationId: organizationId!, + }, + }); + + if (!existingRisk) { + return NextResponse.json( + { + success: false, + error: "Risk not found", + }, + { status: 404 } + ); + } + + // Delete the risk + await db.risk.delete({ + where: { + id: riskId, + }, + }); + + return NextResponse.json({ + success: true, + data: { + message: "Risk deleted successfully", + }, + }); + } catch (error) { + console.error("Error deleting risk:", error); + return NextResponse.json( + { + success: false, + error: "Failed to delete risk", + }, + { status: 500 } + ); + } } diff --git a/apps/app/src/app/api/v1/risks/route.ts b/apps/app/src/app/api/v1/risks/route.ts index 6da2a13ec7..4e2a597f2e 100644 --- a/apps/app/src/app/api/v1/risks/route.ts +++ b/apps/app/src/app/api/v1/risks/route.ts @@ -1,10 +1,10 @@ import { db } from "@comp/db"; import { - Departments, - RiskCategory, - RiskStatus, - Likelihood, - Impact, + Departments, + RiskCategory, + RiskStatus, + Likelihood, + Impact, } from "@comp/db/types"; import { NextResponse, type NextRequest } from "next/server"; import { getOrganizationFromApiKey } from "@/lib/api-key"; @@ -15,30 +15,30 @@ export const runtime = "nodejs"; // Define the schema for query parameters const queryParamsSchema = z.object({ - status: z.nativeEnum(RiskStatus).optional(), - category: z.nativeEnum(RiskCategory).optional(), - department: z.nativeEnum(Departments).optional(), - search: z.string().optional(), + status: z.nativeEnum(RiskStatus).optional(), + category: z.nativeEnum(RiskCategory).optional(), + department: z.nativeEnum(Departments).optional(), + search: z.string().optional(), }); // Define the schema for risk creation const riskCreateSchema = z.object({ - title: z.string().min(1, { message: "Title is required" }), - description: z.string().min(1, { message: "Description is required" }), - category: z.nativeEnum(RiskCategory), - department: z.nativeEnum(Departments).optional(), - status: z.nativeEnum(RiskStatus).optional().default(RiskStatus.open), - likelihood: z - .nativeEnum(Likelihood) - .optional() - .default(Likelihood.very_unlikely), - impact: z.nativeEnum(Impact).optional().default(Impact.insignificant), - residualLikelihood: z - .nativeEnum(Likelihood) - .optional() - .default(Likelihood.very_unlikely), - residualImpact: z.nativeEnum(Impact).optional().default(Impact.insignificant), - ownerId: z.string().optional().nullable(), + title: z.string().min(1, { message: "Title is required" }), + description: z.string().min(1, { message: "Description is required" }), + category: z.nativeEnum(RiskCategory), + department: z.nativeEnum(Departments).optional(), + status: z.nativeEnum(RiskStatus).optional().default(RiskStatus.open), + likelihood: z + .nativeEnum(Likelihood) + .optional() + .default(Likelihood.very_unlikely), + impact: z.nativeEnum(Impact).optional().default(Impact.insignificant), + residualLikelihood: z + .nativeEnum(Likelihood) + .optional() + .default(Likelihood.very_unlikely), + residualImpact: z.nativeEnum(Impact).optional().default(Impact.insignificant), + assigneeId: z.string().optional().nullable(), }); // Type for the validated risk creation data @@ -65,127 +65,127 @@ type RiskCreateInput = z.infer; * - 500: { error: "Failed to fetch risks" } */ export async function GET(request: NextRequest) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } - try { - // Get query parameters - const searchParams = request.nextUrl.searchParams; + try { + // Get query parameters + const searchParams = request.nextUrl.searchParams; - // Create an object from the search params - const queryParamsObj = { - status: searchParams.get("status") || undefined, - category: searchParams.get("category") || undefined, - department: searchParams.get("department") || undefined, - search: searchParams.get("search") || undefined, - }; + // Create an object from the search params + const queryParamsObj = { + status: searchParams.get("status") || undefined, + category: searchParams.get("category") || undefined, + department: searchParams.get("department") || undefined, + search: searchParams.get("search") || undefined, + }; - // Validate query parameters - const validationResult = queryParamsSchema.safeParse(queryParamsObj); + // Validate query parameters + const validationResult = queryParamsSchema.safeParse(queryParamsObj); - if (!validationResult.success) { - return NextResponse.json( - { - success: false, - error: "Validation failed", - details: validationResult.error.format(), - }, - { status: 400 }, - ); - } + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: "Validation failed", + details: validationResult.error.format(), + }, + { status: 400 } + ); + } - // Extract validated query parameters - const { status, category, department, search } = validationResult.data; + // Extract validated query parameters + const { status, category, department, search } = validationResult.data; - // Build the where clause - const where: any = { - organizationId: organizationId!, - }; + // Build the where clause + const where: any = { + organizationId: organizationId!, + }; - // Add status filter if provided - if (status) { - where.status = status; - } + // Add status filter if provided + if (status) { + where.status = status; + } - // Add category filter if provided - if (category) { - where.category = category; - } + // Add category filter if provided + if (category) { + where.category = category; + } - // Add department filter if provided - if (department) { - where.department = department; - } + // Add department filter if provided + if (department) { + where.department = department; + } - // Add search filter if provided - if (search) { - where.OR = [ - { - title: { - contains: search, - mode: "insensitive", - }, - }, - { - description: { - contains: search, - mode: "insensitive", - }, - }, - ]; - } + // Add search filter if provided + if (search) { + where.OR = [ + { + title: { + contains: search, + mode: "insensitive", + }, + }, + { + description: { + contains: search, + mode: "insensitive", + }, + }, + ]; + } - // Fetch risks - const risks = await db.risk.findMany({ - where, - select: { - id: true, - title: true, - description: true, - category: true, - department: true, - status: true, - likelihood: true, - impact: true, - residualLikelihood: true, - residualImpact: true, - createdAt: true, - updatedAt: true, - ownerId: true, - owner: { - select: { - id: true, - name: true, - email: true, - }, - }, - }, - orderBy: { - updatedAt: "desc", - }, - }); + // Fetch risks + const risks = await db.risk.findMany({ + where, + select: { + id: true, + title: true, + description: true, + category: true, + department: true, + status: true, + likelihood: true, + impact: true, + residualLikelihood: true, + residualImpact: true, + createdAt: true, + updatedAt: true, + assigneeId: true, + owner: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + }); - // Format dates for JSON response - const formattedRisks = risks.map((risk) => ({ - ...risk, - createdAt: risk.createdAt.toISOString(), - updatedAt: risk.updatedAt.toISOString(), - })); + // Format dates for JSON response + const formattedRisks = risks.map((risk) => ({ + ...risk, + createdAt: risk.createdAt.toISOString(), + updatedAt: risk.updatedAt.toISOString(), + })); - return NextResponse.json({ success: true, data: formattedRisks }); - } catch (error) { - console.error("Error fetching risks:", error); - return NextResponse.json( - { success: false, error: "Failed to fetch risks" }, - { status: 500 }, - ); - } + return NextResponse.json({ success: true, data: formattedRisks }); + } catch (error) { + console.error("Error fetching risks:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch risks" }, + { status: 500 } + ); + } } /** @@ -206,7 +206,7 @@ export async function GET(request: NextRequest) { * - impact: Impact - The impact score (optional, defaults to "insignificant") * - residualLikelihood: Likelihood - The residual likelihood score (optional, defaults to "very_unlikely") * - residualImpact: Impact - The residual impact score (optional, defaults to "insignificant") - * - ownerId: string - The ID of the user who owns the risk (optional) + * - assigneeId: string - The ID of the user who owns the risk (optional) * * Returns: * - 200: { success: true, data: Risk } @@ -215,63 +215,63 @@ export async function GET(request: NextRequest) { * - 500: { success: false, error: "Failed to create risk" } */ export async function POST(request: NextRequest) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } - try { - const body = await request.json(); + try { + const body = await request.json(); - // Validate the request body against the schema - const validationResult = riskCreateSchema.safeParse(body); + // Validate the request body against the schema + const validationResult = riskCreateSchema.safeParse(body); - if (!validationResult.success) { - // Return validation errors - return NextResponse.json( - { - success: false, - error: "Validation failed", - details: validationResult.error.format(), - }, - { status: 400 }, - ); - } + if (!validationResult.success) { + // Return validation errors + return NextResponse.json( + { + success: false, + error: "Validation failed", + details: validationResult.error.format(), + }, + { status: 400 } + ); + } - // Extract validated data - const validatedData: RiskCreateInput = validationResult.data; + // Extract validated data + const validatedData: RiskCreateInput = validationResult.data; - // Create the risk using the organization ID from the API key - const risk = await db.risk.create({ - data: { - ...validatedData, - organizationId: organizationId!, - }, - }); + // Create the risk using the organization ID from the API key + const risk = await db.risk.create({ + data: { + ...validatedData, + organizationId: organizationId!, + }, + }); - // Format dates for JSON response - const formattedRisk = { - ...risk, - createdAt: risk.createdAt.toISOString(), - updatedAt: risk.updatedAt.toISOString(), - }; + // Format dates for JSON response + const formattedRisk = { + ...risk, + createdAt: risk.createdAt.toISOString(), + updatedAt: risk.updatedAt.toISOString(), + }; - return NextResponse.json({ - success: true, - data: formattedRisk, - }); - } catch (error) { - console.error("Error creating risk:", error); - return NextResponse.json( - { - success: false, - error: "Failed to create risk", - }, - { status: 500 }, - ); - } + return NextResponse.json({ + success: true, + data: formattedRisk, + }); + } catch (error) { + console.error("Error creating risk:", error); + return NextResponse.json( + { + success: false, + error: "Failed to create risk", + }, + { status: 500 } + ); + } } diff --git a/apps/app/src/components/forms/risks/task/update-task-form.tsx b/apps/app/src/components/forms/risks/task/update-task-form.tsx index 4724b64968..e5ce4aecb0 100644 --- a/apps/app/src/components/forms/risks/task/update-task-form.tsx +++ b/apps/app/src/components/forms/risks/task/update-task-form.tsx @@ -58,7 +58,7 @@ export function UpdateTaskForm({ defaultValues: { id: task.id, dueDate: task.dueDate ? new Date(task.dueDate) : undefined, - ownerId: task.userId ?? undefined, + assigneeId: task.userId ?? undefined, status: task.status ?? TaskStatus.open, }, }); @@ -66,7 +66,7 @@ export function UpdateTaskForm({ updateTask.execute({ id: data.id, dueDate: data.dueDate ? data.dueDate : undefined, - ownerId: data.ownerId, + assigneeId: data.assigneeId, status: data.status as TaskStatus, }); }; @@ -77,7 +77,7 @@ export function UpdateTaskForm({
( {t("common.assignee.label")} diff --git a/apps/app/src/components/forms/risks/task/update-task-overview-form.tsx b/apps/app/src/components/forms/risks/task/update-task-overview-form.tsx index f3a8b30b05..392fd64d08 100644 --- a/apps/app/src/components/forms/risks/task/update-task-overview-form.tsx +++ b/apps/app/src/components/forms/risks/task/update-task-overview-form.tsx @@ -49,7 +49,7 @@ export function UpdateTaskOverviewForm({ description: task.description, dueDate: task.dueDate ? new Date(task.dueDate) : undefined, status: task.status, - ownerId: task.userId, + assigneeId: task.userId, }, }); @@ -60,7 +60,7 @@ export function UpdateTaskOverviewForm({ description: values.description, dueDate: values.dueDate, status: values.status, - ownerId: values.ownerId, + assigneeId: values.assigneeId, }); }; diff --git a/apps/app/src/components/forms/risks/update-risk-form.tsx b/apps/app/src/components/forms/risks/update-risk-form.tsx index 5716315605..b9814a31d6 100644 --- a/apps/app/src/components/forms/risks/update-risk-form.tsx +++ b/apps/app/src/components/forms/risks/update-risk-form.tsx @@ -50,7 +50,7 @@ export function UpdateRiskForm({ category: risk.category, department: risk.department ?? Departments.admin, status: risk.status, - ownerId: risk.ownerId ?? undefined, + assigneeId: risk.assigneeId ?? undefined, }, }); @@ -62,7 +62,7 @@ export function UpdateRiskForm({ category: data.category, department: data.department, status: data.status, - ownerId: data.ownerId, + assigneeId: data.assigneeId, }); }; diff --git a/apps/app/src/components/policies/charts/policies-by-assignee.tsx b/apps/app/src/components/policies/charts/policies-by-assignee.tsx index 0acdad7fc7..40d65e9b9e 100644 --- a/apps/app/src/components/policies/charts/policies-by-assignee.tsx +++ b/apps/app/src/components/policies/charts/policies-by-assignee.tsx @@ -1,5 +1,6 @@ import { getI18n } from "@/locales/server"; import { db } from "@comp/db"; +import { PolicyStatus } from "@comp/db/types"; import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import type { CSSProperties } from "react"; @@ -36,7 +37,7 @@ export async function PoliciesByAssignee({ organizationId }: Props) { const stats: UserPolicyStats[] = userStats.map((user) => { const userPolicies = policies.filter( - (policy) => policy.ownerId === user.id, + (policy) => policy.assigneeId === user.id, ); return { @@ -47,15 +48,15 @@ export async function PoliciesByAssignee({ organizationId }: Props) { }, totalPolicies: userPolicies.length, publishedPolicies: userPolicies.filter( - (policy) => policy.status === "published", + (policy) => policy.status === PolicyStatus.published, ).length, - draftPolicies: userPolicies.filter((policy) => policy.status === "draft") - .length, - archivedPolicies: userPolicies.filter( - (policy) => policy.status === "archived", + draftPolicies: userPolicies.filter( + (policy) => policy.status === PolicyStatus.draft, ).length, + archivedPolicies: userPolicies.filter((policy) => policy.isArchived) + .length, needsReviewPolicies: userPolicies.filter( - (policy) => policy.status === "needs_review", + (policy) => policy.status === PolicyStatus.needs_review, ).length, }; }); @@ -221,8 +222,9 @@ const policiesByUser = async (organizationId: string) => { organizationId, }, select: { - ownerId: true, + assigneeId: true, status: true, + isArchived: true, }, }); }; diff --git a/apps/app/src/components/tables/policies/filter-toolbar.tsx b/apps/app/src/components/tables/policies/filter-toolbar.tsx index b7acb3fe35..06a881a8ab 100644 --- a/apps/app/src/components/tables/policies/filter-toolbar.tsx +++ b/apps/app/src/components/tables/policies/filter-toolbar.tsx @@ -47,7 +47,7 @@ export function FilterToolbar({ isEmpty = false, users }: FilterToolbarProps) { parse: (value) => value || null, }); - const [ownerId, setOwnerId] = useQueryState("ownerId", { + const [assigneeId, setAssigneeId] = useQueryState("assigneeId", { shallow: false, history: "push", parse: (value) => value || null, @@ -78,13 +78,13 @@ export function FilterToolbar({ isEmpty = false, users }: FilterToolbarProps) { startTransition(() => { setSearch(null); setStatus(null); - setOwnerId(null); + setAssigneeId(null); setSort(null); setSearchInput(""); }); - }, [setSearch, setStatus, setOwnerId, setSort]); + }, [setSearch, setStatus, setAssigneeId, setSort]); - const hasFilters = search || status || ownerId || sort; + const hasFilters = search || status || assigneeId || sort; const handleStatusChange = (value: string) => { setStatus(value === "all" ? null : value); @@ -154,11 +154,11 @@ export function FilterToolbar({ isEmpty = false, users }: FilterToolbarProps) { - - {t("vendors.risks.very_unlikely")} + + {t("vendors.risks.very_likely")} (Top row) - - {t("vendors.risks.unlikely")} + + {t("vendors.risks.likely")} {t("vendors.risks.possible")} - - {t("vendors.risks.likely")} + + {t("vendors.risks.unlikely")} - - {t("vendors.risks.very_likely")} + + {t("vendors.risks.very_unlikely")} (Bottom row) @@ -151,7 +152,9 @@ export function InherentRiskForm({ name="inherentImpact" render={({ field }) => ( - {t("vendors.risks.inherent_impact")} + + {t("vendors.risks.inherent_impact")} (Horizontal Axis) + diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx index 3117b4d003..7e213e989f 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx @@ -19,7 +19,7 @@ import { } from "@comp/ui/select"; import { useToast } from "@comp/ui/use-toast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { updateVendorResidualRisk } from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk"; @@ -50,6 +50,7 @@ export function ResidualRiskForm({ const t = useI18n(); const { toast } = useToast(); const router = useRouter(); + const searchParams = useSearchParams(); const [_, setOpen] = useQueryState("residual-risk-sheet"); const form = useForm({ @@ -62,6 +63,9 @@ export function ResidualRiskForm({ async function onSubmit(values: FormValues) { try { + // Set this immediately to force closing the sheet + setOpen(null); + const response = await updateVendorResidualRisk({ vendorId, residualProbability: values.residualProbability, @@ -79,19 +83,14 @@ export function ResidualRiskForm({ return; } + // Show success toast toast({ title: t("common.success"), description: t("vendors.risks.residual_risk_updated"), }); - // Refresh router first for data update - router.refresh(); - - // Close the sheet after successful submission - setOpen(null); - - // Call onSuccess callback if provided - if (onSuccess) onSuccess(); + // Force page refresh rather than router manipulation + window.location.reload(); } catch (error) { console.error("Error submitting form:", error); toast({ @@ -110,7 +109,9 @@ export function ResidualRiskForm({ name="residualProbability" render={({ field }) => ( - {t("vendors.risks.residual_probability")} + + {t("vendors.risks.residual_probability")} (Vertical Axis) + @@ -151,7 +152,9 @@ export function ResidualRiskForm({ name="residualImpact" render={({ field }) => ( - {t("vendors.risks.residual_impact")} + + {t("vendors.risks.residual_impact")} (Horizontal Axis) + diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/page.tsx index d9307141fe..03a3ec3edb 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/page.tsx @@ -1,17 +1,9 @@ "use server"; -import { DataTable } from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/data-table/data-table"; -import { - NoResults, - NoTasks, -} from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/data-table/empty-states"; -import { FilterToolbar } from "@/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/tasks/data-table/filter-toolbar"; -import { Loading } from "@/components/frameworks/loading"; import { getI18n } from "@/locales/server"; import { auth } from "@comp/auth"; import { db } from "@comp/db"; import type { TaskStatus } from "@comp/db/types"; -import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; import { headers } from "next/headers"; From 738e9f539a4a4c46134a40f1245e3715a4c4b245 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 18:27:10 -0400 Subject: [PATCH 4/5] refactor: update assigneeId field in schemas to be optional - Changed assigneeId in createRiskSchema, updateRiskSchema, createTaskSchema, updateTaskSchema, and updatePolicyFormSchema to be optional and nullable for improved flexibility in form submissions. --- apps/app/src/actions/schema.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 2c5c03c10d..44748a37da 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -91,7 +91,7 @@ export const createRiskSchema = z.object({ department: z.nativeEnum(Departments, { required_error: "Risk department is required", }), - assigneeId: z.string().nullable(), + assigneeId: z.string().optional().nullable(), }); export const updateRiskSchema = z.object({ @@ -110,7 +110,7 @@ export const updateRiskSchema = z.object({ department: z.nativeEnum(Departments, { required_error: "Risk department is required", }), - assigneeId: z.string().nullable(), + assigneeId: z.string().optional().nullable(), status: z.nativeEnum(RiskStatus, { required_error: "Risk status is required", }), @@ -141,13 +141,7 @@ export const createTaskSchema = z.object({ message: "Task description is required", }), dueDate: z.date().optional(), - assigneeId: z - .string({ - required_error: "You must assign an owner to the task", - }) - .min(1, { - message: "You must assign an owner to the task", - }), + assigneeId: z.string().optional().nullable(), }); export const updateTaskSchema = z.object({ @@ -160,9 +154,7 @@ export const updateTaskSchema = z.object({ status: z.nativeEnum(TaskStatus, { required_error: "Task status is required", }), - assigneeId: z.string({ - required_error: "You must assign an owner to the task", - }), + assigneeId: z.string().optional().nullable(), }); export const createTaskCommentSchema = z.object({ @@ -262,7 +254,7 @@ export const updatePolicyOverviewSchema = z.object({ export const updatePolicyFormSchema = z.object({ id: z.string(), status: z.nativeEnum(PolicyStatus), - assigneeId: z.string().nullable(), + assigneeId: z.string().optional().nullable(), department: z.nativeEnum(Departments), review_frequency: z.nativeEnum(Frequency), review_date: z.date(), From b135bfc3740ce905bcfde6fa433bb5ece068e316 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 18:47:27 -0400 Subject: [PATCH 5/5] refactor: enhance risk chart components and forms with improved logic and UI - Added visual order for likelihood rendering in InherentRiskVendorChart and ResidualRiskVendorChart for better clarity. - Streamlined grid cell rendering logic to improve performance and maintainability. - Updated form submission logic in InherentRiskForm and ResidualRiskForm to ensure proper page refresh and state management after successful updates. - Removed unnecessary console logs and improved user feedback through toast notifications. --- .../components/inherent-risk-vendor-chart.tsx | 144 ++++++++++-------- .../components/residual-risk-vendor-chart.tsx | 136 +++++++++-------- .../forms/risks/inherent-risk-form.tsx | 22 ++- .../forms/risks/residual-risk-form.tsx | 22 ++- 4 files changed, 182 insertions(+), 142 deletions(-) diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx index f02f0c9693..5f6204cb5c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/inherent-risk-vendor-chart.tsx @@ -48,6 +48,14 @@ export function InherentRiskVendorChart({ vendor }: InherentRiskChartProps) { const t = useI18n(); const [open, setOpen] = useQueryState("inherent-risk-sheet"); + // Debug information + console.log("VENDOR DATA:", { + inherentProbability: vendor.inherentProbability, + inherentImpact: vendor.inherentImpact, + probabilityScore: LIKELIHOOD_SCORES[vendor.inherentProbability], + impactScore: IMPACT_SCORES[vendor.inherentImpact], + }); + // Calculate risk score from probability and impact const riskScore = LIKELIHOOD_SCORES[vendor.inherentProbability] * @@ -62,43 +70,29 @@ export function InherentRiskVendorChart({ vendor }: InherentRiskChartProps) { // Get color based on risk level const riskColor = RISK_COLORS[riskLevel as keyof typeof RISK_COLORS]; - // Generate the 5x5 matrix - const matrix = [] as { - x: number; - y: number; - color: string; - isActive: boolean; - }[]; - - for (let impactScore = 5; impactScore >= 1; impactScore--) { - for (let likelihoodScore = 1; likelihoodScore <= 5; likelihoodScore++) { - const cellScore = likelihoodScore * impactScore; - let cellColor = RISK_COLORS.low; - - if (cellScore > 16) cellColor = RISK_COLORS.critical; - else if (cellScore > 9) cellColor = RISK_COLORS.high; - else if (cellScore > 4) cellColor = RISK_COLORS.medium; - - matrix.push({ - x: likelihoodScore, - y: impactScore, - color: cellColor, - isActive: - likelihoodScore === LIKELIHOOD_SCORES[vendor.inherentProbability] && - impactScore === IMPACT_SCORES[vendor.inherentImpact], - }); - } - } + // Define the visual order of likelihood for rendering + const VISUAL_LIKELIHOOD_ORDER: Likelihood[] = [ + Likelihood.very_likely, + Likelihood.likely, + Likelihood.possible, + Likelihood.unlikely, + Likelihood.very_unlikely, + ]; + // We'll determine the active cell while building the grid const yAxisLabels = [ - "V.Likely", + "V.Likely", // Corresponds to VISUAL_LIKELIHOOD_ORDER[0] "Likely", "Possible", "Unlikely", - "V.Unlikely", + "V.Unlikely", // Corresponds to VISUAL_LIKELIHOOD_ORDER[4] ]; const xAxisLabels = ["Insig", "Minor", "Mod", "Major", "Severe"]; + // Store the active cell position values + const activeProbability = vendor.inherentProbability; + const activeImpact = vendor.inherentImpact; + return ( <> @@ -137,41 +131,63 @@ export function InherentRiskVendorChart({ vendor }: InherentRiskChartProps) {
{/* Grid rows */} - {[...Array(5)].map((_, rowIndex) => ( -
- {/* Grid cells for this row */} - {[...Array(5)].map((_, colIndex) => { - // Find the corresponding cell in our matrix data - const cell = matrix.find( - (m) => m.x === colIndex + 1 && m.y === 5 - rowIndex, - ); - - return ( -
- {cell?.isActive && ( -
-
-
- )} -
- ); - })} -
- ))} + {[...Array(5)].map((_, rowIndex) => { + // Map row index to likelihood enum based on visual order + const rowLikelihood = VISUAL_LIKELIHOOD_ORDER[rowIndex]; + + return ( +
+ {/* Grid cells for this row */} + {[...Array(5)].map((_, colIndex) => { + // Map column index to impact enum + const colImpact = Object.keys(IMPACT_SCORES)[ + colIndex + ] as Impact; + + // Calculate cell score and color + const likelihoodScore = + LIKELIHOOD_SCORES[rowLikelihood]; + const impactScore = IMPACT_SCORES[colImpact]; + const cellScore = likelihoodScore * impactScore; + + let cellColor = RISK_COLORS.low; + if (cellScore > 16) cellColor = RISK_COLORS.critical; + else if (cellScore > 9) cellColor = RISK_COLORS.high; + else if (cellScore > 4) + cellColor = RISK_COLORS.medium; + + // Check if this is the active cell + const isActive = + rowLikelihood === activeProbability && + colImpact === activeImpact; + + return ( +
+ {isActive && ( +
+
+
+ )} +
+ ); + })} +
+ ); + })} {/* Y-axis labels - positioned outside the grid */} {yAxisLabels.map((label, i) => ( diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx index 8bcbfaaead..62d22920fc 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/components/residual-risk-vendor-chart.tsx @@ -62,43 +62,29 @@ export function ResidualRiskVendorChart({ vendor }: ResidualRiskChartProps) { // Get color based on risk level const riskColor = RISK_COLORS[riskLevel as keyof typeof RISK_COLORS]; - // Generate the 5x5 matrix - const matrix = [] as { - x: number; - y: number; - color: string; - isActive: boolean; - }[]; - - for (let impactScore = 5; impactScore >= 1; impactScore--) { - for (let likelihoodScore = 1; likelihoodScore <= 5; likelihoodScore++) { - const cellScore = likelihoodScore * impactScore; - let cellColor = RISK_COLORS.low; - - if (cellScore > 16) cellColor = RISK_COLORS.critical; - else if (cellScore > 9) cellColor = RISK_COLORS.high; - else if (cellScore > 4) cellColor = RISK_COLORS.medium; - - matrix.push({ - x: likelihoodScore, - y: impactScore, - color: cellColor, - isActive: - likelihoodScore === LIKELIHOOD_SCORES[vendor.residualProbability] && - impactScore === IMPACT_SCORES[vendor.residualImpact], - }); - } - } + // Define the visual order of likelihood for rendering + const VISUAL_LIKELIHOOD_ORDER: Likelihood[] = [ + Likelihood.very_likely, + Likelihood.likely, + Likelihood.possible, + Likelihood.unlikely, + Likelihood.very_unlikely, + ]; + // We'll determine the active cell while building the grid const yAxisLabels = [ - "V.Likely", + "V.Likely", // Corresponds to VISUAL_LIKELIHOOD_ORDER[0] "Likely", "Possible", "Unlikely", - "V.Unlikely", + "V.Unlikely", // Corresponds to VISUAL_LIKELIHOOD_ORDER[4] ]; const xAxisLabels = ["Insig", "Minor", "Mod", "Major", "Severe"]; + // Store the active cell position values + const activeProbability = vendor.residualProbability; + const activeImpact = vendor.residualImpact; + return ( <> @@ -137,41 +123,63 @@ export function ResidualRiskVendorChart({ vendor }: ResidualRiskChartProps) {
{/* Grid rows */} - {[...Array(5)].map((_, rowIndex) => ( -
- {/* Grid cells for this row */} - {[...Array(5)].map((_, colIndex) => { - // Find the corresponding cell in our matrix data - const cell = matrix.find( - (m) => m.x === colIndex + 1 && m.y === 5 - rowIndex, - ); - - return ( -
- {cell?.isActive && ( -
-
-
- )} -
- ); - })} -
- ))} + {[...Array(5)].map((_, rowIndex) => { + // Map row index to likelihood enum based on visual order + const rowLikelihood = VISUAL_LIKELIHOOD_ORDER[rowIndex]; + + return ( +
+ {/* Grid cells for this row */} + {[...Array(5)].map((_, colIndex) => { + // Map column index to impact enum + const colImpact = Object.keys(IMPACT_SCORES)[ + colIndex + ] as Impact; + + // Calculate cell score and color + const likelihoodScore = + LIKELIHOOD_SCORES[rowLikelihood]; + const impactScore = IMPACT_SCORES[colImpact]; + const cellScore = likelihoodScore * impactScore; + + let cellColor = RISK_COLORS.low; + if (cellScore > 16) cellColor = RISK_COLORS.critical; + else if (cellScore > 9) cellColor = RISK_COLORS.high; + else if (cellScore > 4) + cellColor = RISK_COLORS.medium; + + // Check if this is the active cell + const isActive = + rowLikelihood === activeProbability && + colImpact === activeImpact; + + return ( +
+ {isActive && ( +
+
+
+ )} +
+ ); + })} +
+ ); + })} {/* Y-axis labels - positioned outside the grid */} {yAxisLabels.map((label, i) => ( diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx index 1b377cfd7d..ac093b73d7 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/inherent-risk-form.tsx @@ -63,9 +63,7 @@ export function InherentRiskForm({ async function onSubmit(values: FormValues) { try { - // Set this immediately to force closing the sheet - setOpen(null); - + // Call the server action const response = await updateVendorInherentRisk({ vendorId, inherentProbability: values.inherentProbability, @@ -80,17 +78,27 @@ export function InherentRiskForm({ description: result?.error || t("common.unexpected_error"), variant: "destructive", }); - return; + return; // Stop execution on failure } - // Show success toast + // On success: + // 1. Show success toast toast({ title: t("common.success"), description: t("vendors.risks.inherent_risk_updated"), }); - // Force page refresh rather than router manipulation - window.location.reload(); + // 2. Refresh the page data first to ensure it's updated before navigation + router.refresh(); + + // 3. Close the sheet by navigating to the same route without the query parameter + const params = new URLSearchParams(searchParams); + params.delete("inherent-risk-sheet"); + const newPath = `${window.location.pathname}${params.size > 0 ? `?${params.toString()}` : ""}`; + router.replace(newPath, { scroll: false }); + + // 4. Call onSuccess callback if provided (optional, might not be needed if navigation handles updates) + if (onSuccess) onSuccess(); } catch (error) { console.error("Error submitting form:", error); toast({ diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx index 7e213e989f..34348c68c9 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/forms/risks/residual-risk-form.tsx @@ -63,9 +63,7 @@ export function ResidualRiskForm({ async function onSubmit(values: FormValues) { try { - // Set this immediately to force closing the sheet - setOpen(null); - + // Call the server action const response = await updateVendorResidualRisk({ vendorId, residualProbability: values.residualProbability, @@ -80,17 +78,27 @@ export function ResidualRiskForm({ description: result?.error || t("common.unexpected_error"), variant: "destructive", }); - return; + return; // Stop execution on failure } - // Show success toast + // On success: + // 1. Show success toast toast({ title: t("common.success"), description: t("vendors.risks.residual_risk_updated"), }); - // Force page refresh rather than router manipulation - window.location.reload(); + // 2. Refresh the page data first to ensure it's updated before navigation + router.refresh(); + + // 3. Close the sheet by navigating to the same route without the query parameter + const params = new URLSearchParams(searchParams); + params.delete("residual-risk-sheet"); + const newPath = `${window.location.pathname}${params.size > 0 ? `?${params.toString()}` : ""}`; + router.replace(newPath, { scroll: false }); + + // 4. Call onSuccess callback if provided (optional, might not be needed if navigation handles updates) + if (onSuccess) onSuccess(); } catch (error) { console.error("Error submitting form:", error); toast({