diff --git a/apps/app/src/actions/add-comment.ts b/apps/app/src/actions/add-comment.ts new file mode 100644 index 0000000000..86202e5c9d --- /dev/null +++ b/apps/app/src/actions/add-comment.ts @@ -0,0 +1,75 @@ +"use server"; + +import { authActionClient } from "./safe-action"; +import { z } from "zod"; +import { db } from "@comp/db"; +import { AppError, appErrors } from "@/lib/errors"; +import { headers } from "next/headers"; +import { revalidatePath } from "next/cache"; + +export const addCommentAction = authActionClient + .schema( + z.object({ + content: z.string(), + entityId: z.string(), + }), + ) + .metadata({ + name: "add-comment", + track: { + event: "add-comment", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { content, entityId } = parsedInput; + const { user, session } = ctx; + + if (!session || !session.activeOrganizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } + + try { + const member = await db.member.findFirst({ + where: { + userId: session.userId, + organizationId: session.activeOrganizationId, + }, + }); + + if (!member) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } + + const comment = await db.comment.create({ + data: { + content, + entityId, + organizationId: session.activeOrganizationId, + authorId: member.id, + }, + }); + + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); + + if (path) { + revalidatePath(path); + } + + return { success: true, data: comment }; + } catch (error) { + return { + success: false, + error: error instanceof AppError ? error : appErrors.UNEXPECTED_ERROR, + }; + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 44748a37da..65a7a3278a 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -1,284 +1,296 @@ import { - Departments, - FrameworkId, - Frequency, - Impact, - Likelihood, - PolicyStatus, - RiskCategory, - RiskStatus, - TaskStatus, + Departments, + FrameworkId, + Frequency, + Impact, + Likelihood, + PolicyStatus, + RiskCategory, + RiskStatus, + TaskStatus, } from "@comp/db/types"; import { z } from "zod"; export const organizationSchema = z.object({ - name: z.string().min(1, "Organization name is required"), - frameworks: z - .array(z.nativeEnum(FrameworkId)) - .min(1, "Please select at least one framework to get started with"), + name: z.string().min(1, "Organization name is required"), + frameworks: z + .array(z.nativeEnum(FrameworkId)) + .min(1, "Please select at least one framework to get started with"), }); export type OrganizationSchema = z.infer; export const organizationNameSchema = z.object({ - name: z - .string() - .min(1, "Organization name is required") - .max(255, "Organization name cannot exceed 255 characters"), + name: z + .string() + .min(1, "Organization name is required") + .max(255, "Organization name cannot exceed 255 characters"), }); export const subdomainAvailabilitySchema = z.object({ - subdomain: z - .string() - .min(1, "Subdomain is required") - .max(255, "Subdomain cannot exceed 255 characters") - .regex(/^[a-z0-9-]+$/, { - message: - "Subdomain can only contain lowercase letters, numbers, and hyphens", - }), + subdomain: z + .string() + .min(1, "Subdomain is required") + .max(255, "Subdomain cannot exceed 255 characters") + .regex(/^[a-z0-9-]+$/, { + message: + "Subdomain can only contain lowercase letters, numbers, and hyphens", + }), }); export const uploadSchema = z.object({ - file: z.instanceof(File), - organizationId: z.string(), + file: z.instanceof(File), + organizationId: z.string(), }); export const deleteOrganizationSchema = z.object({ - id: z.string(), - organizationId: z.string(), + id: z.string(), + organizationId: z.string(), }); export const sendFeedbackSchema = z.object({ - feedback: z.string(), + feedback: z.string(), }); export const updaterMenuSchema = z.array( - z.object({ - path: z.string(), - name: z.string(), - }) + z.object({ + path: z.string(), + name: z.string(), + }), ); export const organizationWebsiteSchema = z.object({ - website: z.string().url().max(255), + website: z.string().url().max(255), }); // Risks export const createRiskSchema = z.object({ - title: z - .string({ - required_error: "Risk name is required", - }) - .min(1, { - message: "Risk name should be at least 1 character", - }) - .max(100, { - message: "Risk name should be at most 100 characters", - }), - description: z - .string({ - required_error: "Risk description is required", - }) - .min(1, { - message: "Risk description should be at least 1 character", - }) - .max(255, { - message: "Risk description should be at most 255 characters", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - assigneeId: z.string().optional().nullable(), + title: z + .string({ + required_error: "Risk name is required", + }) + .min(1, { + message: "Risk name should be at least 1 character", + }) + .max(100, { + message: "Risk name should be at most 100 characters", + }), + description: z + .string({ + required_error: "Risk description is required", + }) + .min(1, { + message: "Risk description should be at least 1 character", + }) + .max(255, { + message: "Risk description should be at most 255 characters", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + assigneeId: z.string().optional().nullable(), }); export const updateRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Risk title is required", - }), - description: z.string().min(1, { - message: "Risk description is required", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - assigneeId: z.string().optional().nullable(), - status: z.nativeEnum(RiskStatus, { - required_error: "Risk status is required", - }), + id: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Risk title is required", + }), + description: z.string().min(1, { + message: "Risk description is required", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + assigneeId: z.string().optional().nullable(), + status: z.nativeEnum(RiskStatus, { + required_error: "Risk status is required", + }), }); export const createRiskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); export const createTaskSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Task title is required", - }), - description: z.string().min(1, { - message: "Task description is required", - }), - dueDate: z.date().optional(), - assigneeId: z.string().optional().nullable(), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Task title is required", + }), + description: z.string().min(1, { + message: "Task description is required", + }), + dueDate: z.date().optional(), + assigneeId: z.string().optional().nullable(), }); export const updateTaskSchema = z.object({ - id: z.string().min(1, { - message: "Task ID is required", - }), - title: z.string().optional(), - description: z.string().optional(), - dueDate: z.date().optional(), - status: z.nativeEnum(TaskStatus, { - required_error: "Task status is required", - }), - assigneeId: z.string().optional().nullable(), + id: z.string().min(1, { + message: "Task ID is required", + }), + title: z.string().optional(), + description: z.string().optional(), + dueDate: z.date().optional(), + status: z.nativeEnum(TaskStatus, { + required_error: "Task status is required", + }), + assigneeId: z.string().optional().nullable(), }); export const createTaskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); export const uploadTaskFileSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), }); // Integrations export const deleteIntegrationConnectionSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); export const createIntegrationSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); // Seed Data export const seedDataSchema = z.object({ - organizationId: z.string(), + organizationId: z.string(), }); export const updateInherentRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.nativeEnum(Likelihood), - impact: z.nativeEnum(Impact), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.nativeEnum(Likelihood), + impact: z.nativeEnum(Impact), }); export const updateResidualRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.number().min(1).max(10), - impact: z.number().min(1).max(10), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.number().min(1).max(10), + impact: z.number().min(1).max(10), }); // Policies export const createPolicySchema = z.object({ - title: z.string(), - description: z.string(), - frameworkIds: z.array(z.string()), - controlIds: z.array(z.string()), + title: z.string(), + description: z.string(), + frameworkIds: z.array(z.string()), + controlIds: z.array(z.string()), }); export const updatePolicySchema = z.object({ - id: z.string(), - content: z.any(), + id: z.string(), + content: z.any(), }); export const assistantSettingsSchema = z.object({ - enabled: z.boolean().optional(), + enabled: z.boolean().optional(), }); export const createEmployeeSchema = z.object({ - name: z.string().min(1, "Name is required"), - email: z.string().email("Invalid email address"), - department: z.nativeEnum(Departments, { - required_error: "Department is required", - }), - externalEmployeeId: z.string().optional(), - isActive: z.boolean().default(true), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + department: z.nativeEnum(Departments, { + required_error: "Department is required", + }), + externalEmployeeId: z.string().optional(), + isActive: z.boolean().default(true), }); export const updatePolicyOverviewSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string(), - isRequiredToSign: z.enum(["required", "not_required"]).optional(), + id: z.string(), + title: z.string(), + description: z.string(), + isRequiredToSign: z.enum(["required", "not_required"]).optional(), }); export const updatePolicyFormSchema = z.object({ - id: z.string(), - status: z.nativeEnum(PolicyStatus), - assigneeId: z.string().optional().nullable(), - department: z.nativeEnum(Departments), - review_frequency: z.nativeEnum(Frequency), - review_date: z.date(), - isRequiredToSign: z.enum(["required", "not_required"]), + id: z.string(), + status: z.nativeEnum(PolicyStatus), + assigneeId: z.string().optional().nullable(), + department: z.nativeEnum(Departments), + review_frequency: z.nativeEnum(Frequency), + review_date: z.date(), + isRequiredToSign: z.enum(["required", "not_required"]), }); export const apiKeySchema = z.object({ - name: z - .string() - .min(1, { message: "Name is required" }) - .max(64, { message: "Name must be less than 64 characters" }), - expiresAt: z.enum(["30days", "90days", "1year", "never"]), + name: z + .string() + .min(1, { message: "Name is required" }) + .max(64, { message: "Name must be less than 64 characters" }), + expiresAt: z.enum(["30days", "90days", "1year", "never"]), }); export const createPolicyCommentSchema = z.object({ - policyId: z.string().min(1, { - message: "Policy ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + policyId: z.string().min(1, { + message: "Policy ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), +}); + +export const addCommentSchema = z.object({ + content: z + .string() + .min(1, "Comment content is required") + .max(1000, "Comment content should be at most 1000 characters") + .transform((val) => { + // Remove any HTML tags + return val.replace(/<[^>]*>/g, ""); + }), + entityId: z.string().min(1, "Entity ID is required"), }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/[policyId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/[policyId]/page.tsx index 151043b345..2231ad9989 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/[policyId]/page.tsx @@ -9,93 +9,133 @@ import { headers } from "next/headers"; import { JSONContent } from "novel"; import { cache } from "react"; import { PolicyPageEditor } from "./editor/components/PolicyDetails"; +import { Comments } from "@/components/comments"; export default async function PolicyDetails({ - params, + params, }: { - params: Promise<{ locale: string; policyId: string; orgId: string }>; + params: Promise<{ locale: string; policyId: string; orgId: string }>; }) { - const { locale, policyId, orgId } = await params; - - setStaticParamsLocale(locale); - const policy = await getPolicy(policyId); - const assignees = await getAssignees(); - - return ( - - - - - ); + const { locale, policyId, orgId } = await params; + + setStaticParamsLocale(locale); + const policy = await getPolicy(policyId); + const assignees = await getAssignees(); + const comments = await getComments({ policyId }); + + return ( + + + + + + ); } export async function generateMetadata({ - params, + params, }: { - params: Promise<{ locale: string; policyId: string }>; + params: Promise<{ locale: string; policyId: string }>; }): Promise { - const { locale } = await params; + const { locale } = await params; - setStaticParamsLocale(locale); - const t = await getI18n(); + setStaticParamsLocale(locale); + const t = await getI18n(); - return { - title: t("policies.overview.title"), - }; + return { + title: t("policies.overview.title"), + }; } const getPolicy = cache(async (policyId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - return null; - } + if (!organizationId) { + return null; + } - const policy = await db.policy.findUnique({ - where: { id: policyId, organizationId }, - }); + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + }); - if (!policy) { - return null; - } + if (!policy) { + return null; + } - return policy; + return policy; }); const getAssignees = cache(async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return []; - } - - const assignees = await db.member.findMany({ - where: { - organizationId, - role: { - notIn: ["employee"], - }, - }, - include: { - user: true, - }, - }); - - return assignees; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; }); + +const getComments = cache(async ({ + policyId, +}: { + policyId: string; +}) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return []; + } + + try { + const comments = await db.comment.findMany({ + where: { entityId: policyId, organizationId }, + include: { + author: { + include: { + user: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return comments; + } catch (error) { + console.error(error); + return []; + } +}); \ No newline at end of file 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 7de52f34c5..560c29ec84 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 @@ -1,3 +1,4 @@ +import { cache } from "react"; import PageWithBreadcrumb from "@/components/pages/PageWithBreadcrumb"; import { InherentRiskChart } from "@/components/risks/charts/inherent-risk-chart"; import { ResidualRiskChart } from "@/components/risks/charts/residual-risk-chart"; @@ -12,6 +13,7 @@ import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { Comments } from "@/components/comments"; interface PageProps { searchParams: Promise<{ @@ -27,31 +29,10 @@ interface PageProps { export default async function RiskPage({ searchParams, params }: PageProps) { const { riskId, orgId } = await params; const risk = await getRisk(riskId); - + const comments = await getComments({ riskId }); const assignees = await getAssignees(); const t = await getI18n(); - const { - search, - status, - sort, - page = "1", - per_page = "5", - } = await searchParams; - - const columnHeaders = await getServerColumnHeaders(); - const [column, order] = sort?.split(":") ?? []; - const hasFilters = !!(search || status); - const { tasks: loadedTasks, total } = await getTasks({ - riskId, - search, - status: status as TaskStatus, - column, - order, - page: Number.parseInt(page), - per_page: Number.parseInt(per_page), - }); - if (!risk) { redirect("/"); } @@ -69,12 +50,13 @@ export default async function RiskPage({ searchParams, params }: PageProps) { + ); } -async function getRisk(riskId: string) { +const getRisk = cache(async (riskId: string) => { const session = await auth.api.getSession({ headers: await headers(), }); @@ -98,113 +80,47 @@ async function getRisk(riskId: string) { }); return risk; -} +}); -async function getTasks({ +const getComments = cache(async ({ riskId, - search, - status, - column, - order, - page = 1, - per_page = 10, }: { riskId: string; - search?: string; - status?: TaskStatus; - column?: string; - order?: string; - page?: number; - per_page?: number; -}) { +}) => { const session = await auth.api.getSession({ headers: await headers(), }); if (!session || !session.session.activeOrganizationId) { - return { tasks: [], total: 0 }; + return []; } - const skip = (page - 1) * per_page; - - const [tasks, total] = await Promise.all([ - db.task - .findMany({ - where: { - relatedId: riskId, - relatedType: "risk", - organizationId: session.session.activeOrganizationId, - AND: [ - search - ? { - OR: [ - { title: { contains: search, mode: "insensitive" } }, - { - description: { contains: search, mode: "insensitive" }, - }, - ], - } - : {}, - status ? { status } : {}, - ], - }, - orderBy: column - ? { - [column]: order === "asc" ? "asc" : "desc", - } - : { - createdAt: "desc", - }, - skip, - take: per_page, - include: { - assignee: { - include: { - user: true, - }, - }, - }, - }) - .then((tasks) => - tasks.map( - (task): RiskTaskType => ({ - id: task.id, - riskId: task.relatedId, - title: task.title, - status: task.status, - dueDate: task.dueDate.toISOString(), - assigneeId: task.assigneeId ?? "", - assignee: { - name: task.assignee?.user.name ?? "", - image: task.assignee?.user.image ?? "", - }, - }), - ), - ), - db.task.count({ + try { + const comments = await db.comment.findMany({ where: { - relatedId: riskId, - relatedType: "risk", + entityId: riskId, organizationId: session.session.activeOrganizationId, - AND: [ - search - ? { - OR: [ - { title: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - ], - } - : {}, - status ? { status } : {}, - ], }, - }), - ]); + include: { + author: { + include: { + user: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); - return { tasks, total }; -} + return comments; + } catch (error) { + console.error(error); + return []; + } +}); -async function getAssignees() { +const getAssignees = cache(async () => { const session = await auth.api.getSession({ headers: await headers(), }); @@ -226,7 +142,7 @@ async function getAssignees() { }); return assignees; -} +}); export async function generateMetadata({ params, 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 711a671e07..d1a311d221 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 @@ -13,222 +13,141 @@ import { ResidualRiskVendorChart } from "./components/residual-risk-vendor-chart import { SecondaryFields } from "./components/secondary-fields/secondary-fields"; import { TitleAndDescription } from "./components/title-and-description/title-and-description"; import PageWithBreadcrumb from "@/components/pages/PageWithBreadcrumb"; +import { cache } from "react"; +import { Comments } from "@/components/comments"; interface PageProps { - searchParams: Promise<{ - search?: string; - status?: string; - sort?: string; - page?: string; - per_page?: string; - }>; - params: Promise<{ vendorId: string; locale: string; orgId: string }>; + params: Promise<{ vendorId: string; locale: string; orgId: string }>; } -export default async function VendorPage({ searchParams, params }: PageProps) { - const { vendorId, orgId } = await params; - const vendor = await getVendor(vendorId); - const assignees = await getAssignees(); - const t = await getI18n(); - - const { - search, - status, - sort, - page = "1", - per_page = "5", - } = await searchParams; - - const [column, order] = sort?.split(":") ?? []; - const hasFilters = !!(search || status); - - const { tasks: loadedTasks, total } = await getTasks({ - vendorId, - search, - status: status as TaskStatus, - column, - order, - page: Number.parseInt(page), - per_page: Number.parseInt(per_page), - }); - - if (!vendor) { - redirect("/"); - } - - return ( - -
- - -
- - -
-
-
- ); +export default async function VendorPage({ params }: PageProps) { + const { vendorId, orgId } = await params; + const vendor = await getVendor(vendorId); + const assignees = await getAssignees(); + const comments = await getComments({ vendorId }); + + if (!vendor) { + redirect("/"); + } + + return ( + +
+ + +
+ + +
+ +
+
+ ); } -async function getVendor(vendorId: string) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session || !session.session.activeOrganizationId) { - return null; - } - - const vendor = await db.vendor.findUnique({ - where: { - id: vendorId, - organizationId: session.session.activeOrganizationId, - }, - include: { - assignee: { - include: { - user: true, - }, - }, - }, - }); - - return vendor; -} - -async function getAssignees() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session || !session.session.activeOrganizationId) { - return []; - } - - const assignees = await db.member.findMany({ - where: { - organizationId: session.session.activeOrganizationId, - role: { - notIn: ["employee"], - }, - }, - include: { - user: true, - }, - }); - - return assignees; -} - -async function getTasks({ - vendorId, - search, - status, - column, - order, - page = 1, - per_page = 10, +const getVendor = cache(async (vendorId: string) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + return null; + } + + const vendor = await db.vendor.findUnique({ + where: { + id: vendorId, + organizationId: session.session.activeOrganizationId, + }, + include: { + assignee: { + include: { + user: true, + }, + }, + }, + }); + + return vendor; +}); + +const getComments = cache(async ({ + vendorId, }: { - vendorId: string; - search?: string; - status?: TaskStatus; - column?: string; - order?: string; - page?: number; - per_page?: number; -}) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session || !session.session.activeOrganizationId) { - return { tasks: [], total: 0 }; - } - - const skip = (page - 1) * per_page; - - const [tasks, total] = await Promise.all([ - db.task - .findMany({ - where: { - relatedId: vendorId, - AND: [ - search - ? { - OR: [ - { title: { contains: search, mode: "insensitive" } }, - { - description: { contains: search, mode: "insensitive" }, - }, - ], - } - : {}, - status ? { status } : {}, - ], - }, - orderBy: column - ? { - [column]: order === "asc" ? "asc" : "desc", - } - : { - createdAt: "desc", - }, - skip, - take: per_page, - include: { - assignee: { - include: { - user: true, - }, - }, - }, - }) - .then((tasks) => - tasks.map((task) => ({ - ...task, - dueDate: task.dueDate ?? new Date(), - assignee: { - name: task.assignee?.user.name ?? "", - image: task.assignee?.user.image ?? "", - }, - })), - ), - db.task.count({ - where: { - relatedId: vendorId, - AND: [ - search - ? { - OR: [ - { title: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - ], - } - : {}, - status ? { status } : {}, - ], - }, - }), - ]); - - return { tasks, total }; -} + vendorId: string; +}) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + return []; + } + + try { + const comments = await db.comment.findMany({ + where: { + entityId: vendorId, + organizationId: session.session.activeOrganizationId, + }, + include: { + author: { + include: { + user: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return comments; + } catch (error) { + console.error(error); + return []; + } +}); + +const getAssignees = cache(async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId: session.session.activeOrganizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; +}); export async function generateMetadata({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }): Promise { - const { locale } = await params; - setStaticParamsLocale(locale); - const t = await getI18n(); + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); - return { - title: t("sidebar.vendors"), - }; + return { + title: t("sidebar.vendors"), + }; } 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 ad47988ce0..5b477e80f2 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,7 +1,6 @@ "use client"; import { useI18n } from "@/locales/client"; -import { useSession } from "@/utils/auth-client"; import { Member, User, VendorCategory, VendorStatus } from "@comp/db/types"; import { Accordion, @@ -30,7 +29,6 @@ import { Textarea } from "@comp/ui/textarea"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRightIcon } from "lucide-react"; 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"; @@ -51,45 +49,12 @@ export function CreateVendorForm({ assignees, }: { assignees: (Member & { user: User })[] }) { const t = useI18n(); - const session = useSession(); - - // Get the same query parameters as the table - const [search] = useQueryState("search"); - const [page] = useQueryState("page", { - defaultValue: 1, - parse: Number.parseInt, - }); - const [pageSize] = useQueryState("pageSize", { - defaultValue: 10, - parse: Number, - }); - const [status] = useQueryState("status", { - defaultValue: null, - parse: (value) => value as VendorStatus | null, - }); - const [category] = useQueryState("category", { - defaultValue: null, - parse: (value) => value as VendorCategory | null, - }); - const [assigneeId] = useQueryState("assigneeId", { - defaultValue: null, - parse: (value) => value, - }); - - const router = useRouter(); + const [_, setCreateVendorSheet] = useQueryState("create-vendor-sheet"); const createVendor = useAction(createVendorAction, { onSuccess: async (data) => { - const organizationId = session.data?.session.activeOrganizationId; - - if (!organizationId) { - toast.error(t("vendors.form.create_vendor_error")); - return; - } - toast.success(t("vendors.form.create_vendor_success")); - - router.push(`/${organizationId}/vendors/${data.data?.data?.id}`); + setCreateVendorSheet(null); }, onError: () => { toast.error(t("vendors.form.create_vendor_error")); diff --git a/apps/app/src/components/comments.tsx b/apps/app/src/components/comments.tsx new file mode 100644 index 0000000000..d6599c32d2 --- /dev/null +++ b/apps/app/src/components/comments.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; +import { Button } from "@comp/ui/button"; +import { CreateCommentForm } from "./forms/create-comment-form"; +import { useI18n } from "@/locales/client"; +import { MessageSquarePlus } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { Avatar, AvatarImage, AvatarFallback } from "@comp/ui/avatar"; +import { Member, User } from "@comp/db/types"; +import { cn } from "@comp/ui/cn"; + +interface Comment { + id: string; + content: string; + authorId: string; + createdAt: Date; + author: Member & { + user: User; + }; +} + +interface CommentsProps { + entityId: string; + comments: Comment[]; +} + +export function Comments({ entityId, comments }: CommentsProps) { + const [isAddingComment, setIsAddingComment] = useState(false); + const t = useI18n(); + + return ( + + + {t("common.comments.title")} + + + + {isAddingComment && ( +
+ +
+ )} +
+ {comments.map((comment, index) => ( +
+
+
+ + + + {comment.author.user.name?.charAt(0) || "?"} + + +
+ + {comment.author.user.name || "Unknown User"} + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + +
+
+
+
+

+ {comment.content} +

+
+
+ ))} + {comments.length === 0 && ( +
+

+ {t("common.comments.empty.description")} +

+
+ )} +
+
+
+ ); +} diff --git a/apps/app/src/components/forms/create-comment-form.tsx b/apps/app/src/components/forms/create-comment-form.tsx new file mode 100644 index 0000000000..a131722c8e --- /dev/null +++ b/apps/app/src/components/forms/create-comment-form.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useAction } from "next-safe-action/hooks"; +import { addCommentAction } from "@/actions/add-comment"; +import { Textarea } from "@comp/ui/textarea"; +import { useForm } from "react-hook-form"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@comp/ui/form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { addCommentSchema } from "@/actions/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@comp/ui/accordion"; +import { Button } from "@comp/ui/button"; +import { useI18n } from "@/locales/client"; +import { ArrowRightIcon } from "lucide-react"; + +export function CreateCommentForm({ + entityId, +}: { + entityId: string; +}) { + const addComment = useAction(addCommentAction, { + onSuccess: () => { + toast.success("Comment added successfully"); + form.reset(); + }, + onError: () => { + toast.error("Error adding comment"); + }, + }); + + const onSubmit = (data: z.infer) => { + addComment.execute({ + ...data, + entityId, + }); + }; + + const form = useForm>({ + resolver: zodResolver(addCommentSchema), + defaultValues: { + content: "", + entityId, + }, + mode: "onChange", + }); + + const t = useI18n(); + + return ( +
+ +
+
+ + + Comment + +
+ ( + + +