From 4b798bc31c68062ddd0c7230972fa97b0af03ae2 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 25 Feb 2025 13:18:02 -0800 Subject: [PATCH 1/3] feat(evidence): Add department and assignee filtering to evidence tasks - Enhance evidence tasks list with department and assignee filtering - Update Prisma schema to support assignee and department for organization evidence - Implement new filter options in EvidenceList and data table columns - Add assignee and department columns to evidence tasks view - Improve file preview and section components with better loading and preview handling - Update hooks and server actions to support new filtering parameters --- .../evidence/Actions/getOrganizationAdmins.ts | 71 ++++++++ .../Actions/getOrganizationEmployees.ts | 69 ++++++++ .../Actions/getOrganizationEvidence.ts | 1 + .../Actions/getOrganizationEvidenceTasks.ts | 26 ++- .../evidence/Components/EvidenceList.tsx | 161 ++++++++++++++++-- .../Components/data-table/columns.tsx | 57 ++++++- .../evidence/Components/data-table/types.ts | 6 + .../evidence/[id]/Actions/assignEvidence.ts | 91 ++++++++++ .../[id]/Actions/updateEvidenceDepartment.ts | 71 ++++++++ .../[id]/Actions/updateEvidenceFrequency.ts | 70 ++++++++ .../[id]/Components/AssigneeSection.tsx | 154 +++++++++++++++++ .../[id]/Components/DepartmentSection.tsx | 78 +++++++++ .../[id]/Components/EvidenceDetails.tsx | 56 +++--- .../evidence/[id]/Components/FileCard.tsx | 125 +++++++++++--- .../evidence/[id]/Components/FileSection.tsx | 26 ++- .../[id]/Components/FrequencySection.tsx | 79 +++++++++ .../[id]/Components/ReviewSection.tsx | 107 ++++++++++++ .../[id]/hooks/useOrganizationAdmins.ts | 38 +++++ .../evidence/hooks/useEvidenceTasks.ts | 36 +++- .../migration.sql | 8 + .../migration.sql | 5 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 8 +- 23 files changed, 1272 insertions(+), 73 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationAdmins.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEmployees.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/assignEvidence.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceDepartment.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceFrequency.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/AssigneeSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/DepartmentSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FrequencySection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/ReviewSection.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/hooks/useOrganizationAdmins.ts create mode 100644 packages/db/prisma/migrations/20250225193214_add_evidence_assignee/migration.sql create mode 100644 packages/db/prisma/migrations/20250225193719_update_evidence_assignee_to_user/migration.sql create mode 100644 packages/db/prisma/migrations/20250225204820_add_department_to_evidence/migration.sql diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationAdmins.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationAdmins.ts new file mode 100644 index 0000000000..a3a7a41452 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationAdmins.ts @@ -0,0 +1,71 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { MembershipRole } from "@prisma/client"; + +export const getOrganizationAdmins = authActionClient + .metadata({ + name: "getOrganizationAdmins", + track: { + event: "get-organization-admins", + channel: "server", + }, + }) + .action(async ({ ctx }) => { + const { user } = ctx; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Find organization members with admin or owner roles + const adminMembers = await db.organizationMember.findMany({ + where: { + organizationId: user.organizationId, + role: { + in: [MembershipRole.admin, MembershipRole.owner], + }, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { + user: { + name: "asc", + }, + }, + }); + + // Transform the data to a simpler format + const admins = adminMembers.map((member) => ({ + id: member.userId, + name: member.user.name || "Unknown", + email: member.user.email || "", + image: member.user.image || null, + role: member.role, + })); + + return { + success: true, + data: admins, + }; + } catch (error) { + console.error("Error fetching organization admins:", error); + return { + success: false, + error: "Failed to fetch organization admins", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEmployees.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEmployees.ts new file mode 100644 index 0000000000..1fe9f52c5e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEmployees.ts @@ -0,0 +1,69 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { MembershipRole } from "@prisma/client"; + +export const getOrganizationAdmins = authActionClient + .metadata({ + name: "getOrganizationAdmins", + track: { + event: "get-organization-admins", + channel: "server", + }, + }) + .action(async ({ ctx }) => { + const { user } = ctx; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Find organization members with admin or owner roles + const adminMembers = await db.organizationMember.findMany({ + where: { + organizationId: user.organizationId, + role: { + in: [MembershipRole.admin, MembershipRole.owner], + }, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + user: { + name: "asc", + }, + }, + }); + + // Transform the data to a simpler format + const admins = adminMembers.map((member) => ({ + id: member.userId, + name: member.user.name || "Unknown", + email: member.user.email || "", + role: member.role, + })); + + return { + success: true, + data: admins, + }; + } catch (error) { + console.error("Error fetching organization admins:", error); + return { + success: false, + error: "Failed to fetch organization admins", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts index 68d681ec47..6cf05925b2 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidence.ts @@ -36,6 +36,7 @@ export const getOrganizationEvidenceById = authActionClient }, include: { evidence: true, + assignee: true, }, }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts index 48775b3443..c40ce3b33b 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getOrganizationEvidenceTasks.ts @@ -1,7 +1,7 @@ "use server"; import { authActionClient } from "@/actions/safe-action"; -import { db, Frequency } from "@bubba/db"; +import { db, Frequency, Departments } from "@bubba/db"; import type { Prisma, OrganizationEvidence } from "@bubba/db"; import { z } from "zod"; @@ -26,6 +26,8 @@ export const getOrganizationEvidenceTasks = authActionClient search: z.string().optional().nullable(), status: z.enum(["published", "draft"]).optional().nullable(), frequency: z.nativeEnum(Frequency).optional().nullable(), + department: z.nativeEnum(Departments).optional().nullable(), + assigneeId: z.string().optional().nullable(), page: z.number().int().positive().optional().default(1), pageSize: z.number().int().positive().optional().default(10), }) @@ -39,7 +41,15 @@ export const getOrganizationEvidenceTasks = authActionClient }) .action(async ({ ctx, parsedInput }) => { const { user } = ctx; - const { search, status, frequency, page, pageSize } = parsedInput; + const { + search, + status, + frequency, + department, + assigneeId, + page, + pageSize, + } = parsedInput; if (!user.organizationId) { return { @@ -57,6 +67,10 @@ export const getOrganizationEvidenceTasks = authActionClient ...(status === "draft" ? { published: false } : {}), // Frequency filter ...(frequency ? { frequency } : {}), + // Department filter + ...(department ? { department } : {}), + // Assignee filter + ...(assigneeId ? { assigneeId } : {}), // Search filter ...(search ? { @@ -100,6 +114,14 @@ export const getOrganizationEvidenceTasks = authActionClient where: whereClause, include: { evidence: true, + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, }, skip, take: pageSize, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx index 7d9bea1c47..92f1c0bfc3 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceList.tsx @@ -14,6 +14,8 @@ import { XCircle, ChevronLeft, ChevronRight, + Building, + User, } from "lucide-react"; import { Button } from "@bubba/ui/button"; import { @@ -25,7 +27,7 @@ import { DropdownMenuCheckboxItem, } from "@bubba/ui/dropdown-menu"; import { Badge } from "@bubba/ui/badge"; -import type { Frequency } from "@bubba/db"; +import type { Frequency, Departments } from "@bubba/db"; import { Select, SelectContent, @@ -34,12 +36,28 @@ import { SelectValue, } from "@bubba/ui/select"; import type { EvidenceTaskRow } from "./data-table/types"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; + +// Define all available departments from the enum +const ALL_DEPARTMENTS: Departments[] = [ + "admin", + "gov", + "hr", + "it", + "itsm", + "qms", +]; + +// Define all available frequencies from the enum +const ALL_FREQUENCIES: Frequency[] = ["monthly", "quarterly", "yearly"]; export const EvidenceList = () => { const t = useI18n(); const [search, setSearch] = useQueryState("search"); const [status, setStatus] = useQueryState("status"); const [frequency, setFrequency] = useQueryState("frequency"); + const [department, setDepartment] = useQueryState("department"); + const [assigneeId, setAssigneeId] = useQueryState("assigneeId"); const [page, setPage] = useQueryState("page", { defaultValue: "1" }); const [pageSize, setPageSize] = useQueryState("pageSize", { defaultValue: "10", @@ -57,6 +75,8 @@ export const EvidenceList = () => { search, status: status as "published" | "draft" | null, frequency: frequency as Frequency | null, + department: department as Departments | null, + assigneeId, page: currentPage, pageSize: currentPageSize, }); @@ -72,24 +92,48 @@ export const EvidenceList = () => { console.log({ evidenceTasks, error }); - // Get unique frequencies for the dropdown + // We now use the predefined frequencies instead of extracting from data const frequencies = useMemo(() => { + return ALL_FREQUENCIES; + }, []); + + // We now use the predefined departments instead of extracting from data + const departments = useMemo(() => { + return ALL_DEPARTMENTS; + }, []); + + // Get unique assignees for the dropdown + const assignees = useMemo(() => { if (!evidenceTasks) return []; - const uniqueFrequencies = new Set(); + const uniqueAssignees = new Map< + string, + { id: string; name: string | null; image: string | null } + >(); + for (const task of evidenceTasks) { - if (task.frequency) { - uniqueFrequencies.add(task.frequency); + // Convert to EvidenceTaskRow to access the assignee property + const taskWithAssignee = task as unknown as EvidenceTaskRow; + if (taskWithAssignee.assignee) { + uniqueAssignees.set(taskWithAssignee.assignee.id, { + id: taskWithAssignee.assignee.id, + name: taskWithAssignee.assignee.name, + image: taskWithAssignee.assignee.image, + }); } } - return Array.from(uniqueFrequencies).sort(); + return Array.from(uniqueAssignees.values()).sort((a, b) => { + return (a.name || "").localeCompare(b.name || ""); + }); }, [evidenceTasks]); // Clear all filters const clearFilters = () => { setStatus(null); setFrequency(null); + setDepartment(null); + setAssigneeId(null); // Reset to first page when clearing filters setPage("1"); }; @@ -107,7 +151,11 @@ export const EvidenceList = () => { }; // Check if any filters are active - const hasActiveFilters = status !== null || frequency !== null; + const hasActiveFilters = + status !== null || + frequency !== null || + department !== null || + assigneeId !== null; if (error) return
Error: {error.message}
; if (!evidenceTasks && !isLoading) return null; @@ -121,6 +169,12 @@ export const EvidenceList = () => { }, })) || []; + // Find the selected assignee name for the badge + const selectedAssigneeName = assigneeId + ? tableData.find((task) => task.assignee?.id === assigneeId)?.assignee + ?.name || "Unknown" + : null; + return (
@@ -140,7 +194,10 @@ export const EvidenceList = () => { Filters {hasActiveFilters && ( - {(status ? 1 : 0) + (frequency ? 1 : 0)} + {(status ? 1 : 0) + + (frequency ? 1 : 0) + + (department ? 1 : 0) + + (assigneeId ? 1 : 0)} )} @@ -188,6 +245,54 @@ export const EvidenceList = () => { ))} + + + Filter by Department + {departments.map((dept) => ( + { + setDepartment(department === dept ? null : dept); + setPage("1"); // Reset to first page when filtering + }} + > +
+ + {dept.replace(/_/g, " ").toUpperCase()} +
+
+ ))} + + + + Filter by Assignee + {assignees.map((assignee) => ( + { + setAssigneeId( + assigneeId === assignee.id ? null : assignee.id + ); + setPage("1"); // Reset to first page when filtering + }} + > +
+ + + + {assignee.name ? assignee.name.charAt(0) : "?"} + + + {assignee.name} +
+
+ ))} + {hasActiveFilters && ( <> @@ -232,6 +337,34 @@ export const EvidenceList = () => { )} + + {department && ( + { + setDepartment(null); + setPage("1"); // Reset to first page when removing filter + }} + > + Department: {department.replace(/_/g, " ").toUpperCase()} + + + )} + + {assigneeId && selectedAssigneeName && ( + { + setAssigneeId(null); + setPage("1"); // Reset to first page when removing filter + }} + > + Assignee: {selectedAssigneeName} + + + )}
@@ -266,20 +399,20 @@ export const EvidenceList = () => {
-
+ Page {currentPage} of {pagination.totalPages} -
+ - +
+
+ + +
+
@@ -78,23 +81,7 @@ export function EvidenceDetails({ id }: EvidenceDetailsProps) {

{evidence.name}

- {!evidence.published && ( - - )} - - - {evidence.frequency && ( - - )} - - - -
{evidence.evidence.name}
+
{evidence.published ? ( <> @@ -108,6 +95,29 @@ export function EvidenceDetails({ id }: EvidenceDetailsProps) { )}
+ {!evidence.published && ( + + )} +
+ + + + + + +
{evidence.evidence.name}
{evidence.description && ( diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx index 8ecd45316b..22a2114868 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/FileCard.tsx @@ -1,8 +1,14 @@ "use client"; +import { useEffect, useState } from "react"; import { Button } from "@bubba/ui/button"; import { Card, CardContent, CardFooter } from "@bubba/ui/card"; -import { Dialog, DialogContent, DialogTrigger } from "@bubba/ui/dialog"; +import { + Dialog, + DialogContent, + DialogTrigger, + DialogTitle, +} from "@bubba/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -20,9 +26,10 @@ import { TooltipProvider, TooltipTrigger, } from "@bubba/ui/tooltip"; -import { ExternalLink, Loader2, Trash } from "lucide-react"; +import { ExternalLink, Loader2, Trash, Maximize2 } from "lucide-react"; import Image from "next/image"; import { FileIcon } from "./FileIcon"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; interface FilePreviewState { url: string | null; @@ -50,36 +57,102 @@ export function FileCard({ const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(fileName); const isPdf = /\.pdf$/i.test(fileName); + // Load preview automatically when component mounts + const [hasLoadedPreview, setHasLoadedPreview] = useState(false); + + useEffect(() => { + if (!hasLoadedPreview && !previewState.url && !previewState.isLoading) { + onPreviewClick(url); + setHasLoadedPreview(true); + } + }, [ + hasLoadedPreview, + onPreviewClick, + previewState.isLoading, + previewState.url, + url, + ]); + return ( - + - - + + + ) : previewState.url && isPdf ? ( +
+