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
+
+
+
+
+
+ Evidence tasks without files or links
+
+
+
+
+
+
+
+ Draft
+
+
+
+
+
+ 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 (
+
+ );
+ }
+
+ 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 (
-
+