diff --git a/apps/app/src/actions/framework/select-frameworks-action.ts b/apps/app/src/actions/framework/select-frameworks-action.ts
index fdb076e575..026f65deb9 100644
--- a/apps/app/src/actions/framework/select-frameworks-action.ts
+++ b/apps/app/src/actions/framework/select-frameworks-action.ts
@@ -149,6 +149,7 @@ const createOrganizationPolicy = async (user: User, frameworkIds: string[]) => {
policyId: policy.id,
status: "draft",
content: policy.content as InputJsonValue[],
+ frequency: policy.frequency,
})),
});
@@ -265,6 +266,7 @@ const createOrganizationEvidence = async (user: User) => {
evidenceId: evidence.id,
name: evidence.name,
description: evidence.description,
+ frequency: evidence.frequency,
})),
});
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 24561bc084..48775b3443 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,13 +1,33 @@
"use server";
import { authActionClient } from "@/actions/safe-action";
-import { db } from "@bubba/db";
+import { db, Frequency } from "@bubba/db";
+import type { Prisma, OrganizationEvidence } from "@bubba/db";
import { z } from "zod";
+// Define the response types for better type safety
+export interface PaginationMetadata {
+ page: number;
+ pageSize: number;
+ totalCount: number;
+ totalPages: number;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+}
+
+export interface EvidenceTasksData {
+ data: OrganizationEvidence[];
+ pagination: PaginationMetadata;
+}
+
export const getOrganizationEvidenceTasks = authActionClient
.schema(
z.object({
search: z.string().optional().nullable(),
+ status: z.enum(["published", "draft"]).optional().nullable(),
+ frequency: z.nativeEnum(Frequency).optional().nullable(),
+ page: z.number().int().positive().optional().default(1),
+ pageSize: z.number().int().positive().optional().default(10),
})
)
.metadata({
@@ -19,7 +39,7 @@ export const getOrganizationEvidenceTasks = authActionClient
})
.action(async ({ ctx, parsedInput }) => {
const { user } = ctx;
- const { search } = parsedInput;
+ const { search, status, frequency, page, pageSize } = parsedInput;
if (!user.organizationId) {
return {
@@ -29,44 +49,78 @@ export const getOrganizationEvidenceTasks = authActionClient
}
try {
- const evidenceTasks = await db.organizationEvidence.findMany({
- where: {
- organizationId: user.organizationId,
- ...(search
- ? {
- OR: [
- {
- name: {
- contains: search,
- mode: "insensitive",
- },
+ // Create the where clause for both count and data queries
+ const whereClause: Prisma.OrganizationEvidenceWhereInput = {
+ organizationId: user.organizationId,
+ // Status filter
+ ...(status === "published" ? { published: true } : {}),
+ ...(status === "draft" ? { published: false } : {}),
+ // Frequency filter
+ ...(frequency ? { frequency } : {}),
+ // Search filter
+ ...(search
+ ? {
+ OR: [
+ {
+ name: {
+ contains: search,
+ mode: "insensitive" as Prisma.QueryMode,
},
- {
- description: {
- contains: search,
- mode: "insensitive",
- },
+ },
+ {
+ description: {
+ contains: search,
+ mode: "insensitive" as Prisma.QueryMode,
},
- {
- evidence: {
- name: {
- contains: search,
- mode: "insensitive",
- },
+ },
+ {
+ evidence: {
+ name: {
+ contains: search,
+ mode: "insensitive" as Prisma.QueryMode,
},
},
- ],
- }
- : {}),
- },
+ },
+ ],
+ }
+ : {}),
+ };
+
+ // Get total count for pagination
+ const totalCount = await db.organizationEvidence.count({
+ where: whereClause,
+ });
+
+ // Calculate pagination values
+ const skip = (page - 1) * pageSize;
+ const totalPages = Math.ceil(totalCount / pageSize);
+
+ // Get paginated data
+ const evidenceTasks = await db.organizationEvidence.findMany({
+ where: whereClause,
include: {
evidence: true,
},
+ skip,
+ take: pageSize,
+ orderBy: {
+ updatedAt: "desc", // Most recently updated first
+ },
});
return {
success: true,
- data: evidenceTasks,
+ data: {
+ data: evidenceTasks,
+ pagination: {
+ page,
+ pageSize,
+ totalCount,
+ totalPages,
+ hasNextPage: page < totalPages,
+ hasPreviousPage: page > 1,
+ },
+ },
};
} catch (error) {
console.error("Error fetching evidence tasks:", error);
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 c50383fde3..7d9bea1c47 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
@@ -4,44 +4,293 @@ import { useOrganizationEvidenceTasks } from "../hooks/useEvidenceTasks";
import { DataTable } from "./data-table/data-table";
import { Input } from "@bubba/ui/input";
import { useQueryState } from "nuqs";
-import { useCallback } from "react";
+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 = () => {
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 });
+ } = 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]
+ [setSearch, setPage]
);
- if (error) return
Error loading evidence tasks
;
+ 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());
+ };
+
+ // 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;
+
+ 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 (
-
Review and upload evidence
-
+
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
+
+
+
+
+
+ Filter by Frequency
+ {frequencies.map((freq) => (
+ {
+ setFrequency(frequency === freq ? null : freq);
+ setPage("1"); // Reset to first page when filtering
+ }}
+ >
+ {freq}
+
+ ))}
+
+ {hasActiveFilters && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {/* Active filter badges */}
+ {status && (
+
{
+ setStatus(null);
+ setPage("1"); // Reset to first page when removing filter
+ }}
+ >
+ Status: {status === "published" ? "Published" : "Draft"}
+
+
+ )}
+
+ {frequency && (
+
{
+ setFrequency(null);
+ setPage("1"); // Reset to first page when removing filter
+ }}
+ >
+ Frequency: {frequency}
+
+
+ )}
- {isLoading ?
:
}
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+ {/* Pagination UI */}
+ {pagination && (
+
+
+ Showing {(currentPage - 1) * currentPageSize + 1} to{" "}
+ {Math.min(currentPage * currentPageSize, pagination.totalCount)}{" "}
+ of {pagination.totalCount} items
+
+
+
+
+
+
+
+
+ Page {currentPage} of {pagination.totalPages}
+
+
+
+
+
+ )}
+ >
+ )}
);
};
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 3dc313822a..67b6a56420 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
@@ -9,59 +9,25 @@ import {
TooltipTrigger,
} from "@bubba/ui/tooltip";
import type { EvidenceTaskRow } from "./types";
+import { calculateNextReview } from "@/lib/utils/calculate-next-review";
+import { format } from "date-fns";
export const columns: ColumnDef
[] = [
{
id: "name",
accessorKey: "name",
header: "Name",
- size: 200,
- cell: ({ row }) => (
-
-
-
- {row.original.name}
-
- {row.original.name}
-
-
- ),
- },
- {
- id: "description",
- accessorKey: "description",
- header: "Description",
- size: 300,
- cell: ({ row }) => {
- const description = row.original.description;
- if (!description) return null;
-
- return (
-
-
-
- {description}
-
- {description}
-
-
- );
- },
- },
- {
- id: "evidence",
- accessorKey: "evidence.name",
- header: "Evidence Type",
- size: 150,
+ enableResizing: true,
+ enableSorting: true,
+ size: 100,
+ minSize: 200,
cell: ({ row }) => (
-
-
- {row.original.evidence.name}
-
+
+ {row.original.name}
- {row.original.evidence.name}
+ {row.original.description}
),
@@ -70,13 +36,16 @@ export const columns: ColumnDef[] = [
id: "status",
accessorKey: "published",
header: "Status",
- size: 100,
+ enableResizing: true,
+ enableSorting: true,
+ size: 150,
+ minSize: 120,
cell: ({ row }) => {
const isPublished = row.original.published;
const label = isPublished ? "Published" : "Draft";
return (
-
+
{isPublished ? (
) : (
@@ -93,4 +62,49 @@ export const columns: ColumnDef
[] = [
);
},
},
+ {
+ id: "frequency",
+ accessorKey: "frequency",
+ header: "Frequency",
+ size: 150,
+ enableResizing: true,
+ minSize: 130,
+ enableSorting: true,
+ cell: ({ row }) => {
+ const frequency = row.original.frequency;
+ if (!frequency) return null;
+
+ return {frequency}
;
+ },
+ },
+ {
+ id: "nextReviewDate",
+ accessorKey: "nextReviewDate",
+ header: "Next Review Date",
+ size: 150,
+ enableResizing: true,
+ minSize: 180,
+ enableSorting: true,
+ cell: ({ row }) => {
+ if (row.original.lastPublishedAt === null) {
+ return ASAP
;
+ }
+
+ const reviewInfo = calculateNextReview(
+ row.original.lastPublishedAt,
+ row.original.frequency
+ );
+
+ if (!reviewInfo) return null;
+
+ return (
+
+ {reviewInfo.daysUntil} days (
+ {format(reviewInfo.nextReviewDate, "MM/dd/yyyy")})
+
+ );
+ },
+ },
];
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx
index 1be2eda65e..f454901ad5 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table-header.tsx
@@ -2,18 +2,62 @@
import type { Table } from "@tanstack/react-table";
import { TableHead, TableHeader, TableRow } from "@bubba/ui/table";
+import { flexRender } from "@tanstack/react-table";
import type { EvidenceTaskRow } from "./types";
+import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
+import { cn } from "@bubba/ui/cn";
-export function DataTableHeader({ table }: { table: Table }) {
+interface DataTableHeaderProps {
+ table: Table;
+}
+
+export function DataTableHeader({ table }: DataTableHeaderProps) {
return (
-
- {table.getAllColumns().map((column) => (
-
- {column.columnDef.header as string}
-
- ))}
-
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : (
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ {{
+ asc:
,
+ desc:
,
+ }[header.column.getIsSorted() as string] ??
+ (header.column.getCanSort() && (
+
+ ))}
+
+ )}
+
+
+ ))}
+
+ ))}
);
}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx
index 57fc3a1316..582f6ca025 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/Components/data-table/data-table.tsx
@@ -1,60 +1,94 @@
"use client";
import {
+ type Column,
flexRender,
getCoreRowModel,
useReactTable,
+ getSortedRowModel,
+ type SortingState,
} from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table";
import { columns } from "./columns";
import { DataTableHeader } from "./data-table-header";
import type { EvidenceTaskRow } from "./types";
import { useRouter } from "next/navigation";
+import { useState } from "react";
export function DataTable({ data }: { data: EvidenceTaskRow[] }) {
const router = useRouter();
+ const [sorting, setSorting] = useState([
+ {
+ id: "name",
+ desc: false,
+ },
+ ]);
+
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ enableColumnResizing: true,
+ columnResizeMode: "onChange",
+ state: {
+ sorting,
+ },
+ onSortingChange: setSorting,
});
return (
-
-
-
-
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => (
- router.push(`/evidence/${row.original.id}`)}
- >
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
-
- ))}
+
+
+
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ router.push(`/evidence/${row.original.id}`)}
+ >
+ {row.getVisibleCells().map((cell) => (
+
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No evidence tasks found.
+
- ))
- ) : (
-
-
- No evidence tasks found.
-
-
- )}
-
-
+ )}
+
+
+
);
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/publishEvidence.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/publishEvidence.ts
new file mode 100644
index 0000000000..a9a58f22b3
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/[id]/Actions/publishEvidence.ts
@@ -0,0 +1,78 @@
+"use server";
+
+import { authActionClient } from "@/actions/safe-action";
+import { db } from "@bubba/db";
+import { z } from "zod";
+
+const schema = z.object({
+ id: z.string(),
+});
+
+interface SuccessResponse {
+ success: true;
+}
+
+interface ErrorResponse {
+ success: false;
+ error: string;
+}
+
+type ActionResponse = SuccessResponse | ErrorResponse;
+
+export const publishEvidence = authActionClient
+ .schema(schema)
+ .metadata({
+ name: "publishEvidence",
+ track: {
+ event: "publish-evidence",
+ channel: "server",
+ },
+ })
+ .action(async ({ parsedInput, ctx }): Promise
=> {
+ const { user } = ctx;
+ const { id } = parsedInput;
+
+ if (!user.organizationId) {
+ return {
+ success: false,
+ error: "Not authorized - no organization found",
+ };
+ }
+
+ try {
+ // Check if evidence exists and belongs to organization
+ const evidence = await db.organizationEvidence.findFirst({
+ where: {
+ id,
+ organizationId: user.organizationId,
+ },
+ });
+
+ if (!evidence) {
+ return {
+ success: false,
+ error: "Evidence not found",
+ };
+ }
+
+ // Update the evidence to mark it as published
+ await db.organizationEvidence.update({
+ where: { id },
+ data: {
+ published: true,
+ lastPublishedAt: new Date(),
+ },
+ });
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ console.error("Error publishing evidence:", error);
+ return {
+ success: false,
+ error:
+ error instanceof Error ? error.message : "Failed to publish evidence",
+ };
+ }
+ });
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 05c4c055ea..b6f5e97547 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
@@ -4,19 +4,31 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@bubba/ui/button";
import { ArrowLeft, CheckCircle2, XCircle } from "lucide-react";
-import { Card, CardContent, CardHeader } from "@bubba/ui/card";
+import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card";
import { Skeleton } from "@bubba/ui/skeleton";
-import { useI18n } from "@/locales/client";
import { useOrganizationEvidence } from "../hooks/useOrganizationEvidence";
import { FileSection } from "./FileSection";
import { UrlSection } from "./UrlSection";
+import { ReviewDateCard } from "./ReviewDateCard";
+import { publishEvidence } from "../Actions/publishEvidence";
+import { useAction } from "next-safe-action/hooks";
+import { toast } from "sonner";
import type { EvidenceDetailsProps } from "../types";
export function EvidenceDetails({ id }: EvidenceDetailsProps) {
- const t = useI18n();
const router = useRouter();
const { data, isLoading, error, mutate } = useOrganizationEvidence({ id });
+ const { execute: publishAction, isExecuting } = useAction(publishEvidence, {
+ onSuccess: () => {
+ toast.success("Evidence published successfully");
+ mutate();
+ },
+ onError: () => {
+ toast.error("Failed to publish evidence, please try again.");
+ },
+ });
+
useEffect(() => {
if (error) {
router.push("/evidence");
@@ -55,17 +67,31 @@ export function EvidenceDetails({ id }: EvidenceDetailsProps) {
return (
-
-
-
{evidence.name}
+
+
+
+
{evidence.name}
+
+ {!evidence.published && (
+
+ )}
+ {evidence.frequency && (
+
+ )}
+
{evidence.evidence.name}
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 84aaada97e..8ecd45316b 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
@@ -51,17 +51,31 @@ export function FileCard({
const isPdf = /\.pdf$/i.test(fileName);
return (
-
-
+
+