diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getEvidenceTasksStats.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getEvidenceTasksStats.ts new file mode 100644 index 0000000000..8cfd322777 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Actions/getEvidenceTasksStats.ts @@ -0,0 +1,122 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import type { Prisma } from "@bubba/db"; +import { z } from "zod"; + +// Define the response types for better type safety +export interface EvidenceTasksStats { + totalCount: number; + emptyCount: number; + draftCount: number; + needsReviewCount: number; + upToDateCount: number; +} + +export const getEvidenceTasksStats = authActionClient + .schema(z.object({})) + .metadata({ + name: "getEvidenceTasksStats", + track: { + event: "get-evidence-tasks-stats", + channel: "server", + }, + }) + .action(async ({ ctx }) => { + const { user } = ctx; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Get all evidence tasks for the organization + const evidenceTasks = await db.organizationEvidence.findMany({ + where: { + organizationId: user.organizationId, + }, + select: { + fileUrls: true, + additionalUrls: true, + published: true, + lastPublishedAt: true, + frequency: true, + }, + }); + + // Calculate statistics + let emptyCount = 0; + let draftCount = 0; + let needsReviewCount = 0; + let upToDateCount = 0; + const totalCount = evidenceTasks.length; + const now = new Date(); + + for (const task of evidenceTasks) { + // Check if task has files or links + const hasContent = + task.fileUrls.length > 0 || task.additionalUrls.length > 0; + + // Check if task is published + const isPublished = task.published; + + // Check if task needs review (published and next review date is in the past) + let nextReviewDate = null; + + if (task.lastPublishedAt && task.frequency) { + // Calculate next review date based on last published date and frequency + const lastPublished = new Date(task.lastPublishedAt); + nextReviewDate = new Date(lastPublished); + + switch (task.frequency) { + case "monthly": + nextReviewDate.setMonth(nextReviewDate.getMonth() + 1); + break; + case "quarterly": + nextReviewDate.setMonth(nextReviewDate.getMonth() + 3); + break; + case "yearly": + nextReviewDate.setFullYear(nextReviewDate.getFullYear() + 1); + break; + } + } + + const isPastDue = nextReviewDate && nextReviewDate < now; + + if (!hasContent) { + // No files or links + emptyCount++; + } else if (!isPublished) { + // Has content but not published + draftCount++; + } else if (isPastDue) { + // Published but needs review + needsReviewCount++; + } else { + // Published and up to date + upToDateCount++; + } + } + + return { + success: true, + data: { + totalCount, + emptyCount, + draftCount, + needsReviewCount, + upToDateCount, + }, + }; + } catch (error) { + console.error("Error fetching evidence tasks stats:", error); + return { + success: false, + error: "Failed to fetch evidence tasks stats", + }; + } + }); 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..42e9cd09ad 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,9 +41,18 @@ 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) { + console.error("Not authorized - no organization found"); return { success: false, error: "Not authorized - no organization found", @@ -57,6 +68,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 +115,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/EvidenceFilters/ActiveFilterBadges.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/ActiveFilterBadges.tsx new file mode 100644 index 0000000000..844b775f58 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/ActiveFilterBadges.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Badge } from "@bubba/ui/badge"; +import { X } from "lucide-react"; +import { useEvidenceTable } from "../../hooks/useEvidenceTableContext"; + +export function ActiveFilterBadges() { + const { + status, + setStatus, + frequency, + setFrequency, + department, + setDepartment, + assigneeId, + setAssigneeId, + setPage, + assignees, + } = useEvidenceTable(); + + // Find the assignee name if there's an active assignee filter + const assigneeName = assigneeId + ? assignees.find((a) => a.id === assigneeId)?.name || "Unknown" + : null; + + // Only render if there are active filters + if (!status && !frequency && !department && !assigneeId) { + return null; + } + + return ( +
+ {status && ( + + Status: {status} + { + setStatus(null); + setPage("1"); + }} + /> + + )} + {frequency && ( + + Frequency: {frequency} + { + setFrequency(null); + setPage("1"); + }} + /> + + )} + {department && ( + + Department: {department.replace(/_/g, " ").toUpperCase()} + { + setDepartment(null); + setPage("1"); + }} + /> + + )} + {assigneeId && assigneeName && ( + + Assignee: {assigneeName} + { + setAssigneeId(null); + setPage("1"); + }} + /> + + )} +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/FilterDropdown.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/FilterDropdown.tsx new file mode 100644 index 0000000000..6adfc34e16 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/FilterDropdown.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { Badge } from "@bubba/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuCheckboxItem, +} from "@bubba/ui/dropdown-menu"; +import { CheckCircle2, Filter, XCircle, Building } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { useEvidenceTable } from "../../hooks/useEvidenceTableContext"; + +export function FilterDropdown() { + const { + status, + setStatus, + frequency, + setFrequency, + department, + setDepartment, + assigneeId, + setAssigneeId, + setPage, + frequencies, + departments, + assignees, + hasActiveFilters, + clearFilters, + } = useEvidenceTable(); + + return ( + + + + + + Filter by Status + { + setStatus(status === "published" ? null : "published"); + setPage("1"); // Reset to first page when filtering + }} + > +
+ + Published +
+
+ { + setStatus(status === "draft" ? null : "draft"); + setPage("1"); // Reset to first page when filtering + }} + > +
+ + Draft +
+
+ + + + Filter by Frequency + {frequencies.map((freq) => ( + { + setFrequency(frequency === freq ? null : freq); + setPage("1"); // Reset to first page when filtering + }} + > + {freq} + + ))} + + + + 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 && ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/PaginationControls.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/PaginationControls.tsx new file mode 100644 index 0000000000..44b6aae7b9 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/PaginationControls.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useEvidenceTable } from "../../hooks/useEvidenceTableContext"; + +export function PaginationControls() { + const { page, setPage, pageSize, setPageSize, pagination } = + useEvidenceTable(); + + if (!pagination) return null; + + const { totalPages, totalCount } = pagination; + const currentPage = Number.parseInt(page, 10); + + const handlePageSizeChange = (value: string) => { + setPageSize(value); + setPage("1"); // Reset to first page when changing page size + }; + + return ( +
+
+ {totalCount} {totalCount === 1 ? "item" : "items"} +
+
+
+

Rows per page

+ +
+
+ +
+ {currentPage} + + of {totalPages || 1} + +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/SearchInput.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/SearchInput.tsx new file mode 100644 index 0000000000..3ca7c05d97 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/SearchInput.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Input } from "@bubba/ui/input"; +import { Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useDebounce } from "use-debounce"; +import { useEvidenceTable } from "../../hooks/useEvidenceTableContext"; + +interface SearchInputProps { + placeholder?: string; +} + +export function SearchInput({ + placeholder = "Search evidence...", +}: SearchInputProps) { + const { search, setSearch, setPage } = useEvidenceTable(); + const [inputValue, setInputValue] = useState(search || ""); + const [debouncedValue] = useDebounce(inputValue, 500); + + // Update search query parameter when debounced value changes + useEffect(() => { + if (debouncedValue === "") { + setSearch(null); + } else { + setSearch(debouncedValue); + } + setPage("1"); // Reset to first page when searching + }, [debouncedValue, setSearch, setPage]); + + return ( +
+ + setInputValue(e.target.value)} + /> +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/index.ts new file mode 100644 index 0000000000..21ad852505 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceFilters/index.ts @@ -0,0 +1,4 @@ +export { FilterDropdown } from "./FilterDropdown"; +export { ActiveFilterBadges } from "./ActiveFilterBadges"; +export { PaginationControls } from "./PaginationControls"; +export { SearchInput } from "./SearchInput"; 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..2290425c14 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 @@ -1,237 +1,47 @@ "use client"; -import { useOrganizationEvidenceTasks } from "../hooks/useEvidenceTasks"; import { DataTable } from "./data-table/data-table"; -import { Input } from "@bubba/ui/input"; -import { useQueryState } from "nuqs"; -import { useCallback, useMemo } from "react"; -import { debounce } from "lodash"; import { useI18n } from "@/locales/client"; import { SkeletonTable } from "./SkeletonTable"; import { - CheckCircle2, - Filter, - XCircle, - ChevronLeft, - ChevronRight, -} from "lucide-react"; -import { Button } from "@bubba/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuCheckboxItem, -} from "@bubba/ui/dropdown-menu"; -import { Badge } from "@bubba/ui/badge"; -import type { Frequency } from "@bubba/db"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@bubba/ui/select"; -import type { EvidenceTaskRow } from "./data-table/types"; - -export const EvidenceList = () => { + FilterDropdown, + ActiveFilterBadges, + PaginationControls, + SearchInput, +} from "./EvidenceFilters"; +import { useEvidenceTable } from "../hooks/useEvidenceTableContext"; +import { EvidenceSummaryCards } from "./EvidenceSummaryCards"; + +export function EvidenceList() { const t = useI18n(); - const [search, setSearch] = useQueryState("search"); - const [status, setStatus] = useQueryState("status"); - const [frequency, setFrequency] = useQueryState("frequency"); - const [page, setPage] = useQueryState("page", { defaultValue: "1" }); - const [pageSize, setPageSize] = useQueryState("pageSize", { - defaultValue: "10", - }); - - const currentPage = Number.parseInt(page, 10); - const currentPageSize = Number.parseInt(pageSize, 10); - - const { - data: evidenceTasks, - pagination, - isLoading, - error, - } = useOrganizationEvidenceTasks({ - search, - status: status as "published" | "draft" | null, - frequency: frequency as Frequency | null, - page: currentPage, - pageSize: currentPageSize, - }); - - const handleSearch = useCallback( - debounce((value: string) => { - setSearch(value || null); - // Reset to first page when searching - setPage("1"); - }, 300), - [setSearch, setPage] - ); - - console.log({ evidenceTasks, error }); - - // Get unique frequencies for the dropdown - const frequencies = useMemo(() => { - if (!evidenceTasks) return []; - - const uniqueFrequencies = new Set(); - for (const task of evidenceTasks) { - if (task.frequency) { - uniqueFrequencies.add(task.frequency); - } - } - - return Array.from(uniqueFrequencies).sort(); - }, [evidenceTasks]); - - // Clear all filters - const clearFilters = () => { - setStatus(null); - setFrequency(null); - // Reset to first page when clearing filters - setPage("1"); - }; - - // Handle page change - const handlePageChange = (newPage: number) => { - setPage(newPage.toString()); - }; + const { evidenceTasks = [], isLoading, error } = useEvidenceTable(); - // Handle page size change - const handlePageSizeChange = (newSize: string) => { - setPageSize(newSize); - // Reset to first page when changing page size - setPage("1"); - }; - - // Check if any filters are active - const hasActiveFilters = status !== null || frequency !== null; + // Add more detailed logging to help debug + console.log("EvidenceList - evidenceTasks:", evidenceTasks); + console.log("EvidenceList - isLoading:", isLoading); + console.log("EvidenceList - error:", error); if (error) return
Error: {error.message}
; - if (!evidenceTasks && !isLoading) return null; - - // Convert the data to the expected format for the DataTable - const tableData: EvidenceTaskRow[] = - evidenceTasks?.map((task) => ({ - ...task, - evidence: { - name: task.name, - }, - })) || []; return (

Evidence Tasks

-
- handleSearch(e.target.value)} - defaultValue={search || ""} - className="max-w-sm" - /> - - - - - - Filter by Status - { - setStatus(status === "published" ? null : "published"); - setPage("1"); // Reset to first page when filtering - }} - > -
- - Published -
-
- { - setStatus(status === "draft" ? null : "draft"); - setPage("1"); // Reset to first page when filtering - }} - > -
- - Draft -
-
+ {/* Summary Cards - handles its own loading state */} + - - - Filter by Frequency - {frequencies.map((freq) => ( - { - setFrequency(frequency === freq ? null : freq); - setPage("1"); // Reset to first page when filtering - }} - > - {freq} - - ))} - - {hasActiveFilters && ( - <> - - - - )} -
-
+
+ {/* Search Input */} +
+ +
- {/* Active filter badges */} - {status && ( - { - setStatus(null); - setPage("1"); // Reset to first page when removing filter - }} - > - Status: {status === "published" ? "Published" : "Draft"} - - - )} + {/* Filter Dropdown */} + - {frequency && ( - { - setFrequency(null); - setPage("1"); // Reset to first page when removing filter - }} - > - Frequency: {frequency} - - - )} + {/* Active Filter Badges */} +
@@ -239,58 +49,18 @@ export const EvidenceList = () => { ) : ( <> - - - {/* Pagination UI */} - {pagination && ( -
-
- Showing {(currentPage - 1) * currentPageSize + 1} to{" "} - {Math.min(currentPage * currentPageSize, pagination.totalCount)}{" "} - of {pagination.totalCount} items -
- -
- - -
- -
- Page {currentPage} of {pagination.totalPages} -
- -
-
+ {evidenceTasks.length === 0 ? ( +
+ No evidence tasks found. Try adjusting your filters.
+ ) : ( + )} + + {/* Pagination Controls */} + )}
); -}; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceSummaryCards.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceSummaryCards.tsx new file mode 100644 index 0000000000..f21df60094 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/EvidenceSummaryCards.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; +import { useEvidenceTable } from "../hooks/useEvidenceTableContext"; +import { FileX, FileCheck, Clock, CheckCircle } from "lucide-react"; +import { useEvidenceTasksStats } from "../hooks/useEvidenceTasksStats"; +import { Skeleton } from "@bubba/ui/skeleton"; + +export function EvidenceSummaryCards() { + const { stats, isLoading, error } = useEvidenceTasksStats(); + + // Handle loading state + if (isLoading) { + return ( +
+ {["empty", "draft", "review", "uptodate"].map((type) => ( + + ))} +
+ ); + } + + // Handle error state + if (error) { + console.error("Error loading evidence task stats:", error); + return ( +
+ Error loading statistics. Please try again later. +
+ ); + } + + // Handle no data state + if (!stats) { + return null; + } + + return ( +
+ + + + Empty + + + +
+
{stats.emptyCount}
+ +
+

+ Evidence tasks without files or links +

+
+
+ + + + + Draft + + + +
+
{stats.draftCount}
+ +
+

+ Has content but not published +

+
+
+ + + + + Needs Review + + + +
+
{stats.needsReviewCount}
+ +
+

+ Published but past due for review +

+
+
+ + + + + Up to Date + + + +
+
+ {stats.upToDateCount}/{stats.totalCount} +
+ +
+

+ Published and current +

+
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx index 67b6a56420..54d84072b6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/columns.tsx @@ -1,13 +1,14 @@ "use client"; import type { ColumnDef } from "@tanstack/react-table"; -import { CheckCircle2, XCircle } from "lucide-react"; +import { CheckCircle2, XCircle, Building } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@bubba/ui/tooltip"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; import type { EvidenceTaskRow } from "./types"; import { calculateNextReview } from "@/lib/utils/calculate-next-review"; import { format } from "date-fns"; @@ -62,6 +63,29 @@ export const columns: ColumnDef[] = [ ); }, }, + { + id: "department", + accessorKey: "department", + header: "Department", + size: 150, + enableResizing: true, + minSize: 130, + enableSorting: true, + cell: ({ row }) => { + const department = row.original.department; + if (!department || department === "none") + return
None
; + + return ( +
+ + + {department.replace(/_/g, " ").toUpperCase()} + +
+ ); + }, + }, { id: "frequency", accessorKey: "frequency", @@ -107,4 +131,35 @@ export const columns: ColumnDef[] = [ ); }, }, + { + id: "assignee", + accessorKey: "assignee", + header: "Assignee", + enableResizing: true, + enableSorting: true, + size: 150, + minSize: 150, + cell: ({ row }) => { + const assignee = row.original.assignee; + + if (!assignee) { + return
Unassigned
; + } + + return ( +
+ + + + {assignee.name ? assignee.name.charAt(0) : "?"} + + + {assignee.name} +
+ ); + }, + }, ]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/index.ts new file mode 100644 index 0000000000..3ca442232e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/index.ts @@ -0,0 +1,3 @@ +export { DataTable } from "./data-table"; +export { columns } from "./columns"; +export type { EvidenceTaskRow } from "./types"; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts index eef8d49e8b..5cf2b12100 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/types.ts @@ -4,4 +4,10 @@ export type EvidenceTaskRow = OrganizationEvidence & { evidence: { name: string; }; + assignee?: { + id: string; + name: string | null; + email: string | null; + image: string | null; + } | null; }; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/index.ts new file mode 100644 index 0000000000..6b6cb79d3d --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/index.ts @@ -0,0 +1,3 @@ +export { EvidenceList } from "./EvidenceList"; +export { SkeletonTable } from "./SkeletonTable"; +export * from "./EvidenceFilters"; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/assignEvidence.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/assignEvidence.ts new file mode 100644 index 0000000000..eeb04334cc --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/assignEvidence.ts @@ -0,0 +1,91 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db } from "@bubba/db"; +import { MembershipRole } from "@prisma/client"; +import { z } from "zod"; + +export const assignEvidence = authActionClient + .schema( + z.object({ + id: z.string(), + assigneeId: z.string().nullable(), + }) + ) + .metadata({ + name: "assignEvidence", + track: { + event: "assign-evidence", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { id, assigneeId } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Verify the evidence exists and belongs to the organization + const evidence = await db.organizationEvidence.findFirst({ + where: { + id, + organizationId: user.organizationId, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + }; + } + + // If assigneeId is provided, verify the user exists and has admin privileges + if (assigneeId) { + const adminMember = await db.organizationMember.findFirst({ + where: { + userId: assigneeId, + organizationId: user.organizationId, + role: { + in: [MembershipRole.admin, MembershipRole.owner], + }, + }, + }); + + if (!adminMember) { + return { + success: false, + error: "User not found or does not have admin privileges", + }; + } + } + + // Update the evidence with the new assignee + const updatedEvidence = await db.organizationEvidence.update({ + where: { + id, + }, + data: { + assigneeId, + updatedAt: new Date(), + }, + }); + + return { + success: true, + data: updatedEvidence, + }; + } catch (error) { + console.error("Error assigning evidence:", error); + return { + success: false, + error: "Failed to assign evidence", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceDepartment.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceDepartment.ts new file mode 100644 index 0000000000..66981343f3 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceDepartment.ts @@ -0,0 +1,71 @@ +"use server"; + +import { z } from "zod"; +import { db, Departments } from "@bubba/db"; +import { authActionClient } from "@/actions/safe-action"; + +const schema = z.object({ + id: z.string(), + department: z + .enum([...Object.values(Departments)] as [string, ...string[]]) + .nullable(), +}); + +export const updateEvidenceDepartment = authActionClient + .schema(schema) + .metadata({ + name: "Update Evidence Department", + track: { + event: "update-evidence-department", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + const { id, department } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + const evidence = await db.organizationEvidence.findUnique({ + where: { id }, + select: { organizationId: true }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + }; + } + + // Verify the user has access to this organization's evidence + if (evidence.organizationId !== user.organizationId) { + return { + success: false, + error: "Not authorized to update this evidence", + }; + } + + // Update the evidence with the new department + await db.organizationEvidence.update({ + where: { id }, + data: { + department: department as Departments | null, + }, + }); + + return { success: true }; + } catch (error) { + console.error("Failed to update evidence department:", error); + return { + success: false, + error: "An unexpected error occurred", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceFrequency.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceFrequency.ts new file mode 100644 index 0000000000..032d609e20 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/updateEvidenceFrequency.ts @@ -0,0 +1,70 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db, Frequency } from "@bubba/db"; +import { z } from "zod"; +import type { ActionResponse } from "@/actions/types"; + +const schema = z.object({ + id: z.string(), + frequency: z.nativeEnum(Frequency).nullable(), +}); + +export const updateEvidenceFrequency = authActionClient + .schema(schema) + .metadata({ + name: "updateEvidenceFrequency", + track: { + event: "update-evidence-frequency", + channel: "server", + }, + }) + .action(async ({ ctx, parsedInput }): Promise => { + const { user } = ctx; + const { id, frequency } = parsedInput; + + if (!user.organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } + + try { + // Check if the evidence exists and belongs to the user's organization + const evidence = await db.organizationEvidence.findFirst({ + where: { + id, + organizationId: user.organizationId, + }, + }); + + if (!evidence) { + return { + success: false, + error: "Evidence not found", + }; + } + + // Update the evidence frequency + const updatedEvidence = await db.organizationEvidence.update({ + where: { + id, + }, + data: { + frequency, + }, + }); + + return { + success: true, + data: updatedEvidence, + }; + } catch (error) { + console.error("Error updating evidence frequency:", error); + return { + success: false, + error: "Failed to update evidence frequency", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/AssigneeSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/AssigneeSection.tsx new file mode 100644 index 0000000000..939cb31199 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/AssigneeSection.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { useAction } from "next-safe-action/hooks"; +import { toast } from "sonner"; +import { assignEvidence } from "../Actions/assignEvidence"; +import { useOrganizationAdmins } from "../hooks/useOrganizationAdmins"; +import type { Admin } from "../hooks/useOrganizationAdmins"; + +interface AssigneeSectionProps { + evidenceId: string; + currentAssigneeId: string | null | undefined; + onSuccess: () => Promise; +} + +export function AssigneeSection({ + evidenceId, + currentAssigneeId, + onSuccess, +}: AssigneeSectionProps) { + const [assigneeId, setAssigneeId] = useState( + currentAssigneeId || null + ); + const { data: admins, isLoading, error } = useOrganizationAdmins(); + const [selectedAdmin, setSelectedAdmin] = useState(null); + + const { execute: updateAssignee, isExecuting } = useAction(assignEvidence, { + onSuccess: async () => { + toast.success("Assignee updated successfully"); + await onSuccess(); + }, + onError: () => { + toast.error("Failed to update assignee"); + }, + }); + + useEffect(() => { + setAssigneeId(currentAssigneeId || null); + }, [currentAssigneeId]); + + useEffect(() => { + if (admins && assigneeId) { + const admin = admins.find((a) => a.id === assigneeId); + setSelectedAdmin(admin || null); + } else { + setSelectedAdmin(null); + } + }, [admins, assigneeId]); + + const handleAssigneeChange = (value: string) => { + const newAssigneeId = value === "none" ? null : value; + setAssigneeId(newAssigneeId); + + if (newAssigneeId && admins) { + const admin = admins.find((a) => a.id === newAssigneeId); + setSelectedAdmin(admin || null); + } else { + setSelectedAdmin(null); + } + + updateAssignee({ id: evidenceId, assigneeId: newAssigneeId }); + }; + + if (isLoading) { + return ( +
+ Assignee: +
+
+ ); + } + + if (error || !admins) { + return ( +
+ Assignee: +

Failed to load

+
+ ); + } + + return ( +
+ + Assignee: + + +
+ +
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/DepartmentSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/DepartmentSection.tsx new file mode 100644 index 0000000000..50bca19200 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/DepartmentSection.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { useAction } from "next-safe-action/hooks"; +import { toast } from "sonner"; +import { Departments } from "@bubba/db"; +import { updateEvidenceDepartment } from "../Actions/updateEvidenceDepartment"; + +interface DepartmentSectionProps { + evidenceId: string; + currentDepartment: string | null; + onSuccess: () => Promise; +} + +export function DepartmentSection({ + evidenceId, + currentDepartment, + onSuccess, +}: DepartmentSectionProps) { + const [department, setDepartment] = useState( + currentDepartment || null + ); + + const { execute: updateDepartment, isExecuting } = useAction( + updateEvidenceDepartment, + { + onSuccess: async () => { + toast.success("Department updated successfully"); + await onSuccess(); + }, + onError: (error) => { + console.error("Error updating department:", error); + toast.error("Failed to update department"); + }, + } + ); + + useEffect(() => { + setDepartment(currentDepartment || null); + }, [currentDepartment]); + + const handleDepartmentChange = (value: string) => { + const newDepartment = value === "none" ? null : value; + setDepartment(newDepartment); + updateDepartment({ id: evidenceId, department: newDepartment }); + }; + + const departmentOptions = Object.values(Departments).filter( + (dept) => dept !== "none" + ); + + return ( + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx index b6f5e97547..1de3df2e38 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Components/EvidenceDetails.tsx @@ -9,7 +9,7 @@ import { Skeleton } from "@bubba/ui/skeleton"; import { useOrganizationEvidence } from "../hooks/useOrganizationEvidence"; import { FileSection } from "./FileSection"; import { UrlSection } from "./UrlSection"; -import { ReviewDateCard } from "./ReviewDateCard"; +import { ReviewSection } from "./ReviewSection"; import { publishEvidence } from "../Actions/publishEvidence"; import { useAction } from "next-safe-action/hooks"; import { toast } from "sonner"; @@ -42,11 +42,14 @@ export function EvidenceDetails({ id }: EvidenceDetailsProps) { if (isLoading) { return (
-
- - +
+
+ + +
+
@@ -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 ? ( +
+