diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceList.tsx index c28dc23580..ae84b5cdf4 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceList.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceList.tsx @@ -1,61 +1,10 @@ "use client"; -import { useEvidenceTasksStats } from "../../hooks/useEvidenceTasksStats"; import { useEvidenceTable } from "../hooks/useEvidenceTableContext"; -import { - EvidenceListEmpty, - EvidenceListError, - EvidenceListSkeleton, -} from "./EvidenceListUIStates"; -import { EvidenceSummaryCards } from "./EvidenceSummaryCards"; -import { FilterDropdown, SearchInput } from "./table/EvidenceFilters"; -import { PaginationControls } from "./table/EvidenceFilters/PaginationControls"; import { EvidenceListTable } from "./table/EvidenceListTable"; export function EvidenceList() { - const { - evidenceTasks, - isLoading, - error, - mutate, - hasActiveFilters, - clearFilters, - pagination, - } = useEvidenceTable(); + const { evidenceTasks } = useEvidenceTable(); - // Also track the loading state of the stats - const { isLoading: isStatsLoading } = useEvidenceTasksStats(); - - // Show loading state if either the list or stats are loading - if (isLoading || isStatsLoading) { - return ; - } - - if (error) { - return ; - } - - return ( -
- {/* Evidence summary statistics cards */} - - -
- - -
- - {evidenceTasks && evidenceTasks.length === 0 ? ( - - ) : ( -
- - {pagination && } -
- )} -
- ); + return ; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceListUIStates.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceListUIStates.tsx index f4e726a430..ce0e75d8f2 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceListUIStates.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/EvidenceListUIStates.tsx @@ -1,43 +1,20 @@ import React from "react"; -import { SkeletonTable } from "./table/SkeletonTable"; import { Alert, AlertDescription, AlertTitle } from "@bubba/ui/alert"; import { AlertCircle, FileQuestion, SearchX } from "lucide-react"; import { useI18n } from "@/locales/client"; import { Button } from "@bubba/ui/button"; -import { Skeleton } from "@bubba/ui/skeleton"; - -/** - * Loading state component for the evidence list - */ -export function EvidenceListSkeleton() { - return ( -
-
- {/* Summary cards skeleton - matching the style in EvidenceSummaryCards */} -
- {["empty", "draft", "review", "uptodate"].map((type) => ( - - ))} -
- - {/* Filter controls skeleton */} -
-
-
-
-
- - {/* Table skeleton */} - -
- ); -} interface ErrorStateProps { error: Error; onRetry?: () => void; } +interface EmptyStateProps { + message?: string; + hasFilters?: boolean; + onClearFilters?: () => void; +} + /** * Error state component for the evidence list */ @@ -65,12 +42,6 @@ export function EvidenceListError({ error, onRetry }: ErrorStateProps) { ); } -interface EmptyStateProps { - message?: string; - hasFilters?: boolean; - onClearFilters?: () => void; -} - /** * Empty state component for the evidence list */ diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/index.ts index 0a68e0ff04..81ee91ca32 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/index.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/index.ts @@ -1,3 +1,2 @@ export { EvidenceList } from "./EvidenceList"; -export { SkeletonTable } from "./table/SkeletonTable"; export * from "./table/EvidenceFilters"; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/FilterDropdown.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/FilterDropdown.tsx deleted file mode 100644 index 9eb417a4e7..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/FilterDropdown.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"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, - AlertTriangle, -} 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, - relevance, - setRelevance, - setPage, - frequencies, - departments, - assignees, - hasActiveFilters, - clearFilters, - } = useEvidenceTable(); - - return ( - - - - - -
- {/* Status, Frequency, and Relevance in first column */} -
- 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 Relevance -
- { - setRelevance(relevance === "relevant" ? null : "relevant"); - setPage("1"); // Reset to first page when filtering - }} - > -
- - Relevant -
-
- { - setRelevance( - relevance === "not-relevant" ? null : "not-relevant", - ); - setPage("1"); // Reset to first page when filtering - }} - > -
- - Not Relevant -
-
-
- - Filter by Frequency -
- {frequencies.map((freq) => ( - { - setFrequency(frequency === freq ? null : freq); - setPage("1"); // Reset to first page when filtering - }} - > - {freq} - - ))} -
-
- - {/* Department and Assignee in second column */} -
- 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/list/components/table/EvidenceFilters/PaginationControls.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/PaginationControls.tsx deleted file mode 100644 index cc89c6a73f..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/PaginationControls.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"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/list/components/table/EvidenceFilters/SearchInput.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/SearchInput.tsx deleted file mode 100644 index 4edcefed76..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/SearchInput.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"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/list/components/table/EvidenceFilters/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/index.ts index 21ad852505..15b35e6f3e 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/index.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/index.ts @@ -1,4 +1 @@ -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/list/components/table/EvidenceListTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceListTable.tsx index b495a5038e..fdbe556334 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceListTable.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceListTable.tsx @@ -1,123 +1,98 @@ "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 { DataTable } from "@/components/ui/data-table"; import { EvidenceListColumns } from "./EvidenceListColumns"; -import { EvidenceListHeader } from "./EvidenceListHeader"; import type { EvidenceTaskRow } from "../../types"; -import { cn } from "@bubba/ui/cn"; -import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useEvidenceTable } from "../../hooks/useEvidenceTableContext"; +import { getFilterCategories } from "./components/filterCategories"; export function EvidenceListTable({ data }: { data: EvidenceTaskRow[] }) { const router = useRouter(); - const [sorting, setSorting] = useState([ - { - id: "name", - desc: false, - }, - ]); - - const table = useReactTable({ - data, - columns: EvidenceListColumns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableColumnResizing: true, - columnResizeMode: "onChange", - defaultColumn: { - minSize: 40, - size: 150, - }, - state: { - sorting, - columnSizing: { - // Make the relevance column smaller - relevance: 100, - }, - }, - onSortingChange: setSorting, - }); + const { + page, + setPage, + pageSize, + setPageSize, + pagination, + search, + setSearch, + status, + setStatus, + frequency, + setFrequency, + department, + setDepartment, + assigneeId, + setAssigneeId, + relevance, + setRelevance, + frequencies, + departments, + assignees, + hasActiveFilters, + clearFilters, + isLoading, + isSearching, + } = useEvidenceTable(); const handleRowClick = (evidenceId: string) => { router.push(`/evidence/${evidenceId}`); }; + const activeFilterCount = [ + status, + frequency, + department, + assigneeId, + relevance, + ].filter(Boolean).length; + + const filterCategories = getFilterCategories({ + status, + setStatus, + relevance, + setRelevance, + frequency, + setFrequency, + department, + setDepartment, + assigneeId, + setAssigneeId, + frequencies, + departments, + assignees, + setPage, + }); + return ( -
-
- - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - handleRowClick(row.original.id)} - > - {row.getVisibleCells().map((cell) => ( - -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
-
{ - // Stop propagation to prevent row click when resizing - e.stopPropagation(); - }} - /> - - ))} - - )) - ) : ( - - - No evidence tasks found. - - - )} - -
-
-
+ handleRowClick(row.id)} + emptyMessage="No evidence tasks found." + isLoading={isLoading || isSearching} + pagination={{ + page: Number(page), + pageSize: Number(pageSize), + totalCount: pagination?.totalCount || 0, + totalPages: pagination?.totalPages || 0, + hasNextPage: pagination?.hasNextPage || false, + hasPreviousPage: pagination?.hasPreviousPage || false, + }} + onPageChange={(page) => setPage(page.toString())} + onPageSizeChange={(pageSize) => setPageSize(pageSize.toString())} + search={{ + value: search || "", + onChange: setSearch, + placeholder: "Search evidence tasks...", + }} + filters={{ + categories: filterCategories, + hasActiveFilters, + onClearFilters: clearFilters, + activeFilterCount, + }} + /> ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/SkeletonTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/SkeletonTable.tsx deleted file mode 100644 index 6ee50126af..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/SkeletonTable.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Skeleton } from "@bubba/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@bubba/ui/table"; -import { cn } from "@bubba/ui/cn"; - -const SKELETON_ROWS = [ - "skeleton-1", - "skeleton-2", - "skeleton-3", - "skeleton-4", - "skeleton-5", -] as const; - -export const SkeletonTable = () => { - return ( - - - - - - - - - - - - - - - - - - - - - - {SKELETON_ROWS.map((key) => ( - - -
- - -
-
- - - - - - - - - - - - -
- ))} -
-
- ); -}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/AssigneeAvatar.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/AssigneeAvatar.tsx new file mode 100644 index 0000000000..61ad74e1ba --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/AssigneeAvatar.tsx @@ -0,0 +1,26 @@ +"use client"; + +interface AssigneeAvatarProps { + assignee: { + name: string | null; + image: string | null; + }; +} + +export function AssigneeAvatar({ assignee }: AssigneeAvatarProps) { + if (assignee.image) { + return ( + {assignee.name + ); + } + + return ( +
+ {(assignee.name || "?").charAt(0)} +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterCategories.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterCategories.tsx new file mode 100644 index 0000000000..990069bf29 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterCategories.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + STATUS_FILTERS, + RELEVANCE_FILTERS, + DEPARTMENT_ICON, +} from "./filterConfigs"; +import { AssigneeAvatar } from "./AssigneeAvatar"; + +interface FilterCategoriesProps { + status: string | null; + setStatus: (status: string | null) => void; + relevance: string | null; + setRelevance: (relevance: string | null) => void; + frequency: string | null; + setFrequency: (frequency: string | null) => void; + department: string | null; + setDepartment: (department: string | null) => void; + assigneeId: string | null; + setAssigneeId: (assigneeId: string | null) => void; + frequencies: string[]; + departments: string[]; + assignees: Array<{ id: string; name: string | null; image: string | null }>; + setPage: (page: string) => void; +} + +export function getFilterCategories({ + status, + setStatus, + relevance, + setRelevance, + frequency, + setFrequency, + department, + setDepartment, + assigneeId, + setAssigneeId, + frequencies, + departments, + assignees, + setPage, +}: FilterCategoriesProps) { + return [ + { + label: "Filter by Status", + items: STATUS_FILTERS.map((filter) => ({ + ...filter, + checked: status === filter.value, + onChange: (checked: boolean) => { + setStatus(checked ? filter.value : null); + setPage("1"); + }, + })), + }, + { + label: "Filter by Relevance", + items: RELEVANCE_FILTERS.map((filter) => ({ + ...filter, + checked: relevance === filter.value, + onChange: (checked: boolean) => { + setRelevance(checked ? filter.value : null); + setPage("1"); + }, + })), + }, + { + label: "Filter by Frequency", + items: frequencies.map((freq) => ({ + label: freq, + value: freq, + checked: frequency === freq, + onChange: (checked: boolean) => { + setFrequency(checked ? freq : null); + setPage("1"); + }, + })), + }, + { + label: "Filter by Department", + items: departments.map((dept) => ({ + label: dept.replace(/_/g, " ").toUpperCase(), + value: dept, + checked: department === dept, + onChange: (checked: boolean) => { + setDepartment(checked ? dept : null); + setPage("1"); + }, + icon: DEPARTMENT_ICON, + })), + maxHeight: "150px", + }, + { + label: "Filter by Assignee", + items: assignees.map((assignee) => ({ + label: assignee.name || "Unknown", + value: assignee.id, + checked: assigneeId === assignee.id, + onChange: (checked: boolean) => { + setAssigneeId(checked ? assignee.id : null); + setPage("1"); + }, + icon: , + })), + maxHeight: "150px", + }, + ]; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterConfigs.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterConfigs.tsx new file mode 100644 index 0000000000..7bec50c8fb --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterConfigs.tsx @@ -0,0 +1,31 @@ +import { CheckCircle2, XCircle, AlertTriangle, Building } from "lucide-react"; + +export const STATUS_FILTERS = [ + { + label: "Published", + value: "published", + icon: , + }, + { + label: "Draft", + value: "draft", + icon: , + }, +] as const; + +export const RELEVANCE_FILTERS = [ + { + label: "Relevant", + value: "relevant", + icon: , + }, + { + label: "Not Relevant", + value: "not-relevant", + icon: , + }, +] as const; + +export const DEPARTMENT_ICON = ( + +); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/hooks/useEvidenceTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/hooks/useEvidenceTableContext.tsx index d5b0481ff3..87821dc8ad 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/hooks/useEvidenceTableContext.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/hooks/useEvidenceTableContext.tsx @@ -1,6 +1,14 @@ "use client"; -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { + createContext, + useContext, + useMemo, + useState, + useRef, + useEffect, + type ReactNode, +} from "react"; import { useQueryState } from "nuqs"; import type { Frequency, Departments } from "@bubba/db"; import { useOrganizationEvidenceTasks } from "../../hooks/useEvidenceTasks"; @@ -14,9 +22,15 @@ interface Assignee { image: string | null; } +interface Filter { + label: string; + value: string; + checked: boolean; +} + interface EvidenceTableContextType { // State - search: string | null; + search: string; status: string | null; frequency: string | null; department: string | null; @@ -24,9 +38,10 @@ interface EvidenceTableContextType { relevance: string | null; page: string; pageSize: string; + filters: Filter[]; // Setters - setSearch: (value: string | null) => void; + setSearch: (value: string) => void; setStatus: (value: string | null) => void; setFrequency: (value: string | null) => void; setDepartment: (value: string | null) => void; @@ -34,6 +49,7 @@ interface EvidenceTableContextType { setRelevance: (value: string | null) => void; setPage: (value: string) => void; setPageSize: (value: string) => void; + setFilters: (filters: Filter[]) => void; mutate: () => void; // Data @@ -47,6 +63,7 @@ interface EvidenceTableContextType { } | undefined; isLoading: boolean; + isSearching: boolean; error: Error | undefined; // Derived data @@ -64,8 +81,10 @@ const EvidenceTableContext = createContext< >(undefined); export function EvidenceTableProvider({ children }: { children: ReactNode }) { - // Query state - const [search, setSearch] = useQueryState("search"); + // Local state for search + const [search, setSearch] = useState(""); + + // Query state for other filters const [status, setStatus] = useQueryState("status"); const [frequency, setFrequency] = useQueryState("frequency"); const [department, setDepartment] = useQueryState("department"); @@ -76,6 +95,18 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { defaultValue: "10", }); + // Filter state + const [filters, setFilters] = useState([ + { label: "Published", value: "published", checked: false }, + { label: "Draft", value: "draft", checked: false }, + { label: "Relevant", value: "relevant", checked: false }, + { label: "Not Relevant", value: "not-relevant", checked: false }, + ]); + + // Track if this is initial load or a search/filter update + const initialLoadCompleted = useRef(false); + const [isSearching, setIsSearching] = useState(false); + const currentPage = Number.parseInt(page, 10); const currentPageSize = Number.parseInt(pageSize, 10); @@ -97,6 +128,44 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { pageSize: currentPageSize, }); + // Track when search params change + useEffect(() => { + if (initialLoadCompleted.current) { + setIsSearching(true); + } + }, [ + search, + status, + frequency, + department, + assigneeId, + relevance, + page, + pageSize, + ]); + + // Track when loading changes + useEffect(() => { + if (isLoading === false) { + // Small delay to ensure UI transitions properly + setTimeout(() => { + initialLoadCompleted.current = true; + setIsSearching(false); + }, 50); + } + }, [isLoading]); + + // Additional safety reset for isSearching when data changes + useEffect(() => { + if (rawEvidenceTasks && isSearching) { + // If we have data, ensure isSearching is eventually set to false + const timer = setTimeout(() => { + setIsSearching(false); + }, 100); + return () => clearTimeout(timer); + } + }, [rawEvidenceTasks, isSearching]); + // Format data for the table const evidenceTasks = useMemo(() => { return rawEvidenceTasks?.map((task) => ({ @@ -154,10 +223,11 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { setAssigneeId(null); setRelevance(null); setPage("1"); // Reset to first page when clearing filters + setFilters(filters.map((f) => ({ ...f, checked: false }))); // Reset all filter checkboxes + setSearch(""); // Clear search }; - // Create context value - const contextValue = { + const contextValue: EvidenceTableContextType = { // State search, status, @@ -167,6 +237,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { relevance, page, pageSize, + filters, // Setters setSearch, @@ -177,12 +248,14 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { setRelevance, setPage, setPageSize, + setFilters, mutate, // Data evidenceTasks, pagination, isLoading, + isSearching, error, // Derived data diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceOverview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceOverview.tsx index 1e892c0e70..4703c7a1b7 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceOverview.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceOverview.tsx @@ -12,6 +12,7 @@ import { EvidenceErrorState, EvidenceEmptyState, } from "./EvidenceUIStates"; +import { EvidenceSummaryCards } from "../../list/components/EvidenceSummaryCards"; export const EvidenceOverview = () => { const { data, isLoading, error } = useEvidenceDashboard(); @@ -31,6 +32,9 @@ export const EvidenceOverview = () => { return (
+ {/* Evidence summary statistics cards */} + + {/* Charts */}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceUIStates.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceUIStates.tsx index b04d92a7d6..d097405bdf 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceUIStates.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/overview/components/EvidenceUIStates.tsx @@ -18,6 +18,13 @@ export const ChartSkeleton = ({ height = "h-64" }: { height?: string }) => ( export const EvidenceOverviewSkeleton = () => { return (
+ {/* Summary cards skeleton */} +
+ {["empty", "draft", "review", "uptodate"].map((type) => ( + + ))} +
+
{/* Department Chart Skeleton */} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts index 823417ea8c..cb9abdc714 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts @@ -2,7 +2,8 @@ import { db } from "@bubba/db"; import { authActionClient } from "@/actions/safe-action"; -import { employeesInputSchema, appErrors } from "../types"; +import { employeesInputSchema } from "../types"; +import { appErrors } from "../types"; export const getEmployees = authActionClient .schema(employeesInputSchema) diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx index ae6fc93280..61e82e9076 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx @@ -1,77 +1,12 @@ "use client"; -import { DataTable } from "@/components/tables/people/data-table"; -import { - NoResults, - NoEmployees, -} from "@/components/tables/people/empty-states"; -import { FilterToolbar } from "@/components/tables/people/filter-toolbar"; -import { Loading } from "@/components/tables/people/loading"; -import { useEmployees } from "../hooks/useEmployees"; -import { useSearchParams } from "next/navigation"; -import type { PersonType } from "@/components/tables/people/columns"; -import { EmployeesListSkeleton } from "./EmployeesListSkeleton"; - -interface EmployeesListProps { - columnHeaders: { - name: string; - email: string; - department: string; - status: string; - }; -} - -export function EmployeesList({ columnHeaders }: EmployeesListProps) { - const searchParams = useSearchParams(); - const search = searchParams.get("search"); - const role = searchParams.get("role"); - const per_page = Number(searchParams.get("per_page")) || 10; - const page = Number(searchParams.get("page")) || 1; - - const { employees, total, isLoading, error } = useEmployees(); - - console.log({ - employees, - }); - - if (isLoading) { - return ; - } - - if (error) { - return ( -
- - -
- ); - } - - const hasFilters = !!(search || role); - - if (employees.length === 0 && !hasFilters) { - return ( -
- - - -
- ); - } +import { EmployeesTable } from "./table/EmployeesTable"; +import { EmployeesTableProvider } from "./table/hooks/useEmployeesTableContext"; +export function EmployeesList() { return ( -
- - {employees.length > 0 ? ( - - ) : ( - - )} -
+ + + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx new file mode 100644 index 0000000000..0cb71c46c8 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet"; +import { DataTable } from "@/components/ui/data-table"; +import { Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useQueryState } from "nuqs"; +import { getColumns } from "./columns"; +import { useEmployeesTable } from "./hooks/useEmployeesTableContext"; + +export function EmployeesTable() { + const router = useRouter(); + const [, setInviteSheetOpen] = useQueryState("invite-user-sheet"); + const { + page, + setPage, + per_page, + setPerPage, + employees, + total, + search, + setSearch, + isLoading, + isSearching, + } = useEmployeesTable(); + + const handleRowClick = (employeeId: string) => { + router.push(`/people/${employeeId}`); + }; + + // Calculate pagination values only when total is defined + const pagination = + total !== undefined + ? { + page: Number(page), + pageSize: Number(per_page), + totalCount: total, + totalPages: Math.ceil(total / Number(per_page)), + hasNextPage: Number(page) * Number(per_page) < total, + hasPreviousPage: Number(page) > 1, + } + : undefined; + + return ( + <> + handleRowClick(row.id)} + emptyMessage="No employees found." + isLoading={isLoading || isSearching} + pagination={pagination} + onPageChange={(page) => setPage(page)} + onPageSizeChange={(pageSize) => setPerPage(pageSize)} + search={{ + value: search || "", + onChange: setSearch, + placeholder: "Search employees...", + }} + ctaButton={{ + label: "Add Employee", + onClick: () => setInviteSheetOpen("true"), + icon: , + }} + /> + + + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx new file mode 100644 index 0000000000..6f5669899a --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + EmployeeStatus, + getEmployeeStatusFromBoolean, +} from "@/components/tables/people/employee-status"; +import type { Employee } from "@bubba/db"; +import { Avatar, AvatarFallback } from "@bubba/ui/avatar"; +import { Badge } from "@bubba/ui/badge"; +import type { ColumnDef } from "@tanstack/react-table"; + +export function getColumns( + onRowClick: (id: string) => void, +): ColumnDef[] { + return [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => { + const employee = row.original; + return ( +
+ + {employee.name[0] || "?"} + +
+
{employee.name}
+
+ {employee.email} +
+
+
+ ); + }, + }, + { + accessorKey: "department", + header: "Department", + cell: ({ row }) => { + const department = row.getValue("department") as string; + return ( + + {department.toUpperCase()} + + ); + }, + }, + { + accessorKey: "isActive", + header: "Status", + cell: ({ row }) => { + const isActive = row.original.isActive; + const status = getEmployeeStatusFromBoolean(isActive); + + return ( +
+ +
+ ); + }, + }, + ]; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx new file mode 100644 index 0000000000..0ee84c7583 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { FilterCategory } from "../types"; + +interface GetFilterCategoriesProps { + role: string; + setRole: (value: string | null) => void; + setPage: (value: string) => void; +} + +export function getFilterCategories({ + role, + setRole, + setPage, +}: GetFilterCategoriesProps): FilterCategory[] { + return [ + { + label: "Role", + items: [ + { + label: "Admin", + value: "admin", + checked: role === "admin", + onChange: (checked) => { + setRole(checked ? "admin" : null); + setPage("1"); + }, + }, + { + label: "Member", + value: "member", + checked: role === "member", + onChange: (checked) => { + setRole(checked ? "member" : null); + setPage("1"); + }, + }, + ], + }, + ]; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx new file mode 100644 index 0000000000..735184a8f4 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + useRef, + useEffect, + type ReactNode, +} from "react"; +import { useQueryState } from "nuqs"; +import { useEmployees } from "../../../hooks/useEmployees"; +import type { AppError } from "../../../types"; + +interface EmployeesTableContextType { + // State + search: string; + setSearch: (value: string) => void; + role: string; + setRole: (value: string) => void; + page: number; + setPage: (value: number) => void; + per_page: number; + setPerPage: (value: number) => void; + employees: any[]; + total: number; + isLoading: boolean; + isSearching: boolean; + hasActiveFilters: boolean; + + // Actions + clearFilters: () => void; +} + +const EmployeesTableContext = createContext< + EmployeesTableContextType | undefined +>(undefined); + +export function EmployeesTableProvider({ children }: { children: ReactNode }) { + // Local state for search with debounce + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const searchTimeoutRef = useRef(undefined); + + // Query state for filters + const [role, setRole] = useQueryState("role"); + const [page, setPage] = useQueryState("page", { defaultValue: "1" }); + const [per_page, setPerPage] = useQueryState("per_page", { + defaultValue: "10", + }); + + // Loading states + const [isSearching, setIsSearching] = useState(false); + const totalCountRef = useRef(0); + + // Fetch data + const { employees, total, isLoading, error } = useEmployees({ + search: debouncedSearch, + role: role ?? "", + page: Number(page), + per_page: Number(per_page), + }); + + // Update debounced search + useEffect(() => { + setIsSearching(true); + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(() => { + setDebouncedSearch(search); + setIsSearching(false); + }, 300); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [search]); + + // Cache total count + useEffect(() => { + if (total !== undefined) { + totalCountRef.current = total; + } + }, [total]); + + // Clear filters + const clearFilters = async () => { + setSearch(""); + setRole(null); + setPage("1"); + setPerPage("10"); + }; + + // Calculate if there are active filters + const hasActiveFilters = useMemo(() => { + return Boolean(role); + }, [role]); + + const value = { + // State + search, + setSearch, + role: role ?? "", + setRole: (value: string) => setRole(value || null), + page: Number(page), + setPage: (value: number) => setPage(String(value)), + per_page: Number(per_page), + setPerPage: (value: number) => setPerPage(String(value)), + employees, + total: totalCountRef.current, + isLoading, + isSearching, + hasActiveFilters, + + // Actions + clearFilters, + }; + + return ( + + {children} + + ); +} + +export function useEmployeesTable() { + const context = useContext(EmployeesTableContext); + if (!context) { + throw new Error( + "useEmployeesTable must be used within a EmployeesTableProvider", + ); + } + return context; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts new file mode 100644 index 0000000000..16ef7e4f85 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts @@ -0,0 +1,34 @@ +import type { User } from "@bubba/db"; + +export interface EmployeesTableProps { + columnHeaders: { + name: string; + email: string; + department: string; + status: string; + }; + users: Array<{ + id: string; + name: string | null; + full_name: string | null; + email: string | null; + role: string; + onboarded: boolean; + emailVerified: Date | null; + image: string | null; + lastLogin: Date | null; + organizationId: string | null; + }>; +} + +export interface FilterCategory { + label: string; + items: { + label: string; + value: string; + checked: boolean; + onChange: (checked: boolean) => void; + icon?: React.ReactNode; + }[]; + maxHeight?: string; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts index 2058bef107..ff517512c7 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts @@ -35,14 +35,12 @@ async function fetchEmployees( return result.data?.data as EmployeesResponse; } -export function useEmployees() { - const searchParams = useSearchParams(); - const search = searchParams.get("search") || undefined; - const role = searchParams.get("role") || undefined; - const page = Number(searchParams.get("page")) || 1; - const per_page = Number(searchParams.get("per_page")) || 10; - - /** SWR for fetching employees */ +export function useEmployees({ + search = "", + role = "", + page = 1, + per_page = 10, +}: EmployeesInput) { const { data, error, @@ -52,8 +50,8 @@ export function useEmployees() { ["employees", { search, role, page, per_page }], () => fetchEmployees({ search, role, page, per_page }), { - revalidateOnFocus: true, - revalidateOnReconnect: true, + revalidateOnFocus: false, + revalidateOnReconnect: false, } ); @@ -106,13 +104,11 @@ export function useEmployees() { return { employees: data?.employees ?? [], - total: data?.total ?? 0, + total: data?.total, isLoading, isMutating, // <--- expose the mutation loader error, - /** Expose the revalidation if needed directly */ revalidateEmployees, - /** Expose the create employee action */ addEmployee, }; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx index f189e85333..99b308721d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx @@ -1,5 +1,4 @@ import { auth } from "@/auth"; -import { getServerColumnHeaders } from "@/components/tables/people/server-columns"; import { getI18n } from "@/locales/server"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; @@ -7,36 +6,34 @@ import { redirect } from "next/navigation"; import { EmployeesList } from "./components/EmployeesList"; export default async function PeoplePage({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }) { - const { locale } = await params; - setStaticParamsLocale(locale); + const { locale } = await params; + setStaticParamsLocale(locale); - const session = await auth(); - const organizationId = session?.user.organizationId; + const session = await auth(); + const organizationId = session?.user.organizationId; - if (!organizationId) { - return redirect("/"); - } + if (!organizationId) { + return redirect("/"); + } - const columnHeaders = await getServerColumnHeaders(); - - return ; + return ; } export async function generateMetadata({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }): Promise { - const { locale } = await params; + const { locale } = await params; - setStaticParamsLocale(locale); - const t = await getI18n(); + setStaticParamsLocale(locale); + const t = await getI18n(); - return { - title: t("sidebar.people"), - }; + return { + title: t("sidebar.people"), + }; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts new file mode 100644 index 0000000000..b43dc872a0 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export interface AppError { + code: string; + message: string; +} + +export const appErrors = { + UNAUTHORIZED: { + code: "UNAUTHORIZED", + message: "You are not authorized to access this resource", + }, + UNEXPECTED_ERROR: { + code: "UNEXPECTED_ERROR", + message: "An unexpected error occurred", + }, +}; + +export interface EmployeesInput { + search?: string; + role?: string; + page?: number; + per_page?: number; +} + +export const employeesInputSchema = z.object({ + search: z.string().optional(), + role: z.string().optional(), + page: z.number().optional(), + per_page: z.number().optional(), +}); + +export interface EmployeesResponse { + employees: any[]; + total: number; +} + +export interface Employee { + id: string; + name: string; + email: string; + department: string; + status: string; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx index 444c3b54fb..5bc0d540be 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx @@ -1,7 +1,5 @@ "use client"; -import type { PolicyType } from "@/components/tables/policies/columns"; -import { DataTable } from "@/components/tables/policies/data-table"; import { NoPolicies, NoResults, @@ -14,10 +12,10 @@ import { Button } from "@bubba/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; import { AlertTriangle } from "lucide-react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { usePolicies } from "../hooks/usePolicies"; -import { PoliciesListSkeleton } from "./PoliciesListSkeleton"; import type { User } from "next-auth"; +import { PoliciesListSkeleton } from "./PoliciesListSkeleton"; +import { PoliciesTable } from "./table/PoliciesTable"; +import { PoliciesTableProvider } from "./table/hooks/usePoliciesTableContext"; interface PoliciesListProps { columnHeaders: { @@ -30,73 +28,12 @@ interface PoliciesListProps { export function PoliciesList({ columnHeaders, users }: PoliciesListProps) { const t = useI18n(); - const searchParams = useSearchParams(); - const search = searchParams.get("search"); - const status = searchParams.get("status"); - const sort = searchParams.get("sort"); - const pageSize = Number(searchParams.get("pageSize")) || 10; - const page = Number(searchParams.get("page")) || 1; - - const { policies, total, isLoading, error } = usePolicies({ - search: search || undefined, - status: status || undefined, - sort: sort || undefined, - page: page || 1, - pageSize: pageSize || 10, - }); - - if (isLoading) { - return ; - } - - if (error) { - return ( -
- - - - {error.message || t("common.errors.unexpected_error")} - - -
- ); - } - - const hasFilters = !!(search || status || sort); - - if (policies.length === 0 && !hasFilters) { - return ( -
-
-

{t("policies.title")}

- - - -
- - - -
- ); - } return ( -
- - - {policies.length > 0 ? ( - - ) : ( - - )} -
+ +
+ +
+
); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx new file mode 100644 index 0000000000..6c87823857 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { DataTable } from "@/components/ui/data-table"; +import { useRouter } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getFilterCategories } from "./components/filterCategories"; +import { getColumns } from "./columns"; +import type { PoliciesTableProps } from "./types"; +import { usePoliciesTable } from "./hooks/usePoliciesTableContext"; + +export function PoliciesTable({ users }: PoliciesTableProps) { + const router = useRouter(); + const { + page, + setPage, + pageSize, + setPageSize, + policies, + total, + search, + setSearch, + status, + setStatus, + ownerId, + setOwnerId, + hasActiveFilters, + clearFilters, + isLoading, + isSearching, + } = usePoliciesTable(); + + const handleRowClick = (policyId: string) => { + router.push(`/policies/all/${policyId}`); + }; + + const activeFilterCount = [status, ownerId].filter(Boolean).length; + + const filterCategories = getFilterCategories({ + status, + setStatus, + ownerId, + setOwnerId, + users, + setPage, + }); + + // Calculate pagination values only when total is defined + const pagination = + total !== undefined + ? { + page: Number(page), + pageSize: Number(pageSize), + totalCount: total, + totalPages: Math.ceil(total / Number(pageSize)), + hasNextPage: Number(page) * Number(pageSize) < total, + hasPreviousPage: Number(page) > 1, + } + : undefined; + + return ( + handleRowClick(row.id)} + emptyMessage="No policies found." + isLoading={isLoading || isSearching} + pagination={pagination} + onPageChange={(page) => setPage(page.toString())} + onPageSizeChange={(pageSize) => setPageSize(pageSize.toString())} + search={{ + value: search || "", + onChange: setSearch, + placeholder: "Search policies...", + }} + filters={{ + categories: filterCategories, + hasActiveFilters, + onClearFilters: clearFilters, + activeFilterCount, + }} + ctaButton={{ + label: "Create Policy", + onClick: () => router.push("/policies/all/new"), + icon: , + }} + /> + ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx new file mode 100644 index 0000000000..0cb128ca71 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import type { PolicyStatus } from "./types"; +import { formatStatus, getStatusStyle } from "./utils"; + +interface PolicyRow { + id: string; + status: PolicyStatus; + createdAt: Date; + updatedAt: Date; + policy: { + id: string; + name: string; + description: string | null; + slug: string; + }; +} + +export function getColumns( + handleRowClick: (policyId: string) => void, +): ColumnDef[] { + return [ + { + id: "name", + header: "Name", + accessorKey: "policy.name", + cell: ({ row }) => { + const name = row.original.policy.name; + return ( +
+ +
+ ); + }, + }, + { + id: "status", + header: "Status", + accessorKey: "status", + cell: ({ row }) => { + const status = row.original.status; + return ( +
+
+ {formatStatus(status)} +
+ ); + }, + }, + { + id: "updatedAt", + header: "Last Updated", + accessorKey: "updatedAt", + cell: ({ row }) => { + const date = new Date(row.original.updatedAt); + return ( +
+ {date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} +
+ ); + }, + }, + ]; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx new file mode 100644 index 0000000000..ae8855fb53 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { STATUS_FILTERS } from "./filterConfigs"; +import type { User } from "next-auth"; + +interface FilterCategoriesProps { + status: string | null; + setStatus: (status: string | null) => void; + ownerId: string | null; + setOwnerId: (ownerId: string | null) => void; + users: User[]; + setPage: (page: string) => void; +} + +export function getFilterCategories({ + status, + setStatus, + ownerId, + setOwnerId, + users, + setPage, +}: FilterCategoriesProps) { + return [ + { + label: "Filter by Status", + items: STATUS_FILTERS.map((filter) => ({ + ...filter, + checked: status === filter.value, + onChange: (checked: boolean) => { + setStatus(checked ? filter.value : null); + setPage("1"); + }, + })), + }, + { + label: "Filter by Owner", + items: users.map((user) => ({ + label: user.name || "Unknown", + value: user.id || "", + checked: ownerId === user.id, + onChange: (checked: boolean) => { + setOwnerId(checked ? user.id || null : null); + setPage("1"); + }, + icon: ( +
+ {user.image ? ( + {user.name + ) : ( +
+ {(user.name || "?").charAt(0)} +
+ )} +
+ ), + })), + maxHeight: "150px", + }, + ]; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx new file mode 100644 index 0000000000..7d1a7d631d --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx @@ -0,0 +1,24 @@ +import { CheckCircle2, XCircle, Archive, AlertTriangle } from "lucide-react"; + +export const STATUS_FILTERS = [ + { + label: "Published", + value: "published", + icon:
, + }, + { + label: "Draft", + value: "draft", + icon:
, + }, + { + label: "Needs Review", + value: "needs_review", + icon:
, + }, + { + label: "Archived", + value: "archived", + icon:
, + }, +] as const; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx new file mode 100644 index 0000000000..bcdfc1e17e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + useRef, + useEffect, + type ReactNode, +} from "react"; +import { useQueryState } from "nuqs"; +import { usePolicies } from "../../../hooks/usePolicies"; + +interface PoliciesTableContextType { + // State + search: string; + status: string | null; + ownerId: string | null; + page: string; + pageSize: string; + + // Setters + setSearch: (value: string) => void; + setStatus: (value: string | null) => void; + setOwnerId: (value: string | null) => void; + setPage: (value: string) => void; + setPageSize: (value: string) => void; + + // Data + policies: any[] | undefined; + total: number | undefined; + isLoading: boolean; + isSearching: boolean; + + // Derived data + hasActiveFilters: boolean; + + // Actions + clearFilters: () => void; +} + +const PoliciesTableContext = createContext< + PoliciesTableContextType | undefined +>(undefined); + +export function PoliciesTableProvider({ children }: { children: ReactNode }) { + // Local state for search + const [search, setSearch] = useState(""); + + // Query state for other filters + const [status, setStatus] = useQueryState("status"); + const [ownerId, setOwnerId] = useQueryState("ownerId"); + const [page, setPage] = useQueryState("page", { defaultValue: "1" }); + const [pageSize, setPageSize] = useQueryState("pageSize", { + defaultValue: "10", + }); + + // Track if this is initial load or a search/filter update + const initialLoadCompleted = useRef(false); + const [isSearching, setIsSearching] = useState(false); + + const currentPage = Number.parseInt(page, 10); + const currentPageSize = Number.parseInt(pageSize, 10); + + // Fetch data + const { policies, total, isLoading } = usePolicies({ + search, + status: status || undefined, + ownerId: ownerId || undefined, + page: currentPage, + pageSize: currentPageSize, + }); + + // Track when search params change + useEffect(() => { + if (initialLoadCompleted.current) { + setIsSearching(true); + } + }, [search, status, ownerId, page, pageSize]); + + // Track when loading changes + useEffect(() => { + if (isLoading === false) { + // Small delay to ensure UI transitions properly + setTimeout(() => { + initialLoadCompleted.current = true; + setIsSearching(false); + }, 50); + } + }, [isLoading]); + + // Additional safety reset for isSearching when data changes + useEffect(() => { + if (policies && isSearching) { + // If we have data, ensure isSearching is eventually set to false + const timer = setTimeout(() => { + setIsSearching(false); + }, 100); + return () => clearTimeout(timer); + } + }, [policies, isSearching]); + + // Check if any filters are active + const hasActiveFilters = useMemo(() => { + return status !== null || ownerId !== null; + }, [status, ownerId]); + + // Clear all filters + const clearFilters = () => { + setStatus(null); + setOwnerId(null); + setPage("1"); // Reset to first page when clearing filters + setSearch(""); // Clear search + }; + + const contextValue: PoliciesTableContextType = { + // State + search, + status, + ownerId, + page, + pageSize, + + // Setters + setSearch, + setStatus, + setOwnerId, + setPage, + setPageSize, + + // Data + policies, + total, // Use the total directly from the API + isLoading, + isSearching, + + // Derived data + hasActiveFilters, + + // Actions + clearFilters, + }; + + return ( + + {children} + + ); +} + +export function usePoliciesTable() { + const context = useContext(PoliciesTableContext); + + if (context === undefined) { + throw new Error( + "usePoliciesTable must be used within a PoliciesTableProvider", + ); + } + + return context; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts new file mode 100644 index 0000000000..920374f603 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts @@ -0,0 +1,13 @@ +import type { User } from "next-auth"; +import type { ReactNode } from "react"; + +export type PolicyStatus = "published" | "draft" | "needs_review" | "archived"; + +export interface PoliciesTableProps { + users: User[]; + ctaButton?: { + label: string; + onClick: () => void; + icon?: ReactNode; + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts new file mode 100644 index 0000000000..4715b718f4 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts @@ -0,0 +1,18 @@ +import type { PolicyStatus } from "./types"; + +export function getStatusStyle(status: PolicyStatus) { + switch (status) { + case "published": + return "bg-[#00DC73]"; + case "draft": + return "bg-[#ffc107]"; + case "needs_review": + return "bg-[#ff0000]"; + case "archived": + return "bg-[#0ea5e9]"; + } +} + +export function formatStatus(status: PolicyStatus) { + return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, " "); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts index 35ee5c4078..4708df8ab7 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts @@ -1,10 +1,7 @@ "use client"; -import { useSearchParams } from "next/navigation"; import useSWR from "swr"; - import { getPolicies } from "../actions/get-policies"; - import type { AppError, PoliciesInput, PoliciesResponse } from "../types"; async function fetchPolicies(input: PoliciesInput): Promise { @@ -53,7 +50,7 @@ export function usePolicies({ return { policies: data?.policies ?? [], - total: data?.total ?? 0, + total: data?.total, isLoading, error, revalidatePolicies, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx new file mode 100644 index 0000000000..9be3e4a96d --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { DataTable } from "@/components/ui/data-table"; +import { useRisks } from "./hooks/useRisks"; +import { columns } from "./components/table/RiskRegisterColumns"; +import type { User, Risk, Departments } from "@bubba/db"; +import type { RiskStatus } from "@bubba/db"; +import { useState } from "react"; +import { useQueryState } from "nuqs"; +import { RiskRegisterFilters } from "./components/table/RiskRegisterFilters"; +import { useOrganizationAdmins } from "../../evidence/[id]/hooks/useOrganizationAdmins"; +import { CreateRiskSheet } from "@/components/sheets/create-risk-sheet"; +import { Plus } from "lucide-react"; +import { useI18n } from "@/locales/client"; + +type RiskRegisterTableRow = Risk & { owner: User | null }; + +export const RiskRegisterTable = () => { + const t = useI18n(); + // State + const [search, setSearch] = useState(""); + const [open, setOpen] = useQueryState("create-risk-sheet"); + + const [page, setPage] = useQueryState("page", { + defaultValue: 1, + parse: Number.parseInt, + }); + const [pageSize, setPageSize] = useQueryState("pageSize", { + defaultValue: 10, + parse: Number, + }); + const [status, setStatus] = useQueryState("status", { + defaultValue: null, + parse: (value) => value as RiskStatus | null, + }); + const [department, setDepartment] = useQueryState( + "department", + { + defaultValue: null, + parse: (value) => value as Departments | null, + }, + ); + const [assigneeId, setAssigneeId] = useQueryState( + "assigneeId", + { + defaultValue: null, + parse: (value) => value, + }, + ); + + const { data, isLoading } = useRisks({ + search: search, + page: Number(page), + pageSize: Number(pageSize), + status, + department, + assigneeId, + }); + + const hasActiveFilters = Boolean(status || department || assigneeId); + + const handleClearFilters = () => { + setStatus(null); + setDepartment(null); + setAssigneeId(null); + setPage(1); + }; + + const departments: Departments[] = [ + "none", + "it", + "hr", + "admin", + "gov", + "itsm", + "qms", + ] as const; + + const { data: admins } = useOrganizationAdmins(); + + const filterCategories = RiskRegisterFilters({ + setPage: (newPage: number) => setPage(newPage), + departments: departments, + assignees: admins || [], + status, + setStatus, + department, + setDepartment, + assigneeId, + setAssigneeId, + }); + + return ( + <> + + columns={columns} + data={data} + isLoading={isLoading} + search={{ + value: search, + onChange: setSearch, + }} + pagination={{ + page: Number(page), + pageSize: Number(pageSize), + totalCount: data.length, + totalPages: Math.ceil(data.length / Number(pageSize)), + hasNextPage: Number(page) < Math.ceil(data.length / Number(pageSize)), + hasPreviousPage: Number(page) > 1, + }} + onPageChange={(newPage) => setPage(newPage)} + onPageSizeChange={(newPageSize) => setPageSize(newPageSize)} + filters={{ + categories: filterCategories, + hasActiveFilters, + onClearFilters: handleClearFilters, + activeFilterCount: [status, department, assigneeId].filter(Boolean) + .length, + }} + ctaButton={{ + label: t("risk.register.empty.create_risk"), + onClick: () => setOpen("true"), + icon: , + }} + /> + + + ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts new file mode 100644 index 0000000000..f7c9fcdc30 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts @@ -0,0 +1,64 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { db, Departments, RiskStatus, Prisma } from "@bubba/db"; +import { z } from "zod"; + +export const getRisks = authActionClient + .schema( + z.object({ + search: z.string().optional(), + page: z.number().optional().default(1), + pageSize: z.number().optional().default(10), + status: z.nativeEnum(RiskStatus).nullable().optional(), + department: z.nativeEnum(Departments).nullable().optional(), + assigneeId: z.string().nullable().optional(), + }) + ) + .metadata({ + name: "get-risks", + track: { + event: "get-risks", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { search, page, pageSize, status, department, assigneeId } = + parsedInput; + const { user } = ctx; + + if (!user.organizationId) { + return { + success: false, + error: "Unauthorized", + }; + } + + const where = { + organizationId: user.organizationId, + ...(search && { + title: { + contains: search, + mode: Prisma.QueryMode.insensitive, + }, + }), + ...(status ? { status } : {}), + ...(department ? { department } : {}), + ...(assigneeId ? { ownerId: assigneeId } : {}), + }; + + const skip = (page - 1) * (pageSize ?? 10); + + const risks = await db.risk.findMany({ + where, + skip, + take: pageSize, + include: { + owner: true, + }, + }); + + return { + data: risks, + }; + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx new file mode 100644 index 0000000000..2a4fdf67ca --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx @@ -0,0 +1,56 @@ +import type { Risk, User } from "@bubba/db"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@bubba/ui/badge"; +import Link from "next/link"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { Status } from "@/components/status"; + +export const columns: ColumnDef[] = [ + { + header: "Risk", + accessorKey: "title", + cell: ({ row }) => { + return ( + {row.original.title} + ); + }, + }, + { + header: "Status", + accessorKey: "status", + cell: ({ row }) => { + return ; + }, + }, + { + header: "Department", + accessorKey: "department", + cell: ({ row }) => { + return ( + + {row.original.department} + + ); + }, + }, + { + header: "Assignee", + accessorKey: "assignee", + cell: ({ row }) => { + return ( +
+ + + + {row.original.owner?.name?.charAt(0) || "?"} + + +

{row.original.owner?.name}

+
+ ); + }, + }, +]; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx new file mode 100644 index 0000000000..6fcf096418 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx @@ -0,0 +1,73 @@ +import { AssigneeAvatar } from "../../../../evidence/list/components/table/components/AssigneeAvatar"; +import type { Departments, User } from "@bubba/db"; +import { RiskStatus } from "@bubba/db"; +import { Status } from "@/components/status"; +import type { Admin } from "../../../../evidence/[id]/hooks/useOrganizationAdmins"; + +export const RiskRegisterFilters = ({ + setPage, + departments, + assignees, + status, + setStatus, + department, + setDepartment, + assigneeId, + setAssigneeId, +}: { + setPage: (page: number) => void; + departments: Departments[]; + assignees: Admin[] | undefined; + status: RiskStatus | null; + setStatus: (status: RiskStatus | null) => void; + department: Departments | null; + setDepartment: (department: Departments | null) => void; + assigneeId: string | null; + setAssigneeId: (assigneeId: string | null) => void; +}) => { + return [ + { + label: "Filter by Status", + items: Object.values(RiskStatus).map((filter) => ({ + label: filter + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + icon: , + value: filter, + checked: status === filter, + onChange: (checked: boolean) => { + setStatus(checked ? filter : null); + setPage(1); + }, + })), + }, + { + label: "Filter by Department", + items: departments.map((dept) => ({ + label: dept.replace(/_/g, " ").toUpperCase(), + value: dept, + checked: department === dept, + onChange: (checked: boolean) => { + setDepartment(checked ? dept : null); + setPage(1); + }, + })), + maxHeight: "150px", + }, + { + label: "Filter by Assignee", + items: (assignees || []).map((assignee) => ({ + label: assignee.name || "Unknown", + value: assignee.id, + checked: assigneeId === assignee.id, + onChange: (checked: boolean) => { + setAssigneeId(checked ? assignee.id : null); + setPage(1); + }, + icon: , + })), + maxHeight: "150px", + }, + ]; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts new file mode 100644 index 0000000000..610fe539d2 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts @@ -0,0 +1,61 @@ +import useSWR from "swr"; +import { getRisks } from "../actions/getRisks"; +import type { Departments, RiskStatus, Risk, User } from "@bubba/db"; + +const fetchRisks = async (input: { + search?: string; + page?: number; + pageSize?: number; + status?: RiskStatus | null; + department?: Departments | null; + assigneeId?: string | null; +}) => { + const response = await getRisks(input); + + if (!response) { + throw new Error("Failed to fetch risks"); + } + + if (response.serverError) { + throw new Error(response.serverError); + } + + if (response.validationErrors) { + throw new Error( + response.validationErrors._errors?.join(", ") || + "Validation error occurred" + ); + } + + return response.data?.data; +}; + +export const useRisks = ({ + search = "", + page = 1, + pageSize = 10, + status, + department, + assigneeId, +}: { + search?: string; + page?: number; + pageSize?: number; + status?: RiskStatus | null; + department?: Departments | null; + assigneeId?: string | null; +}) => { + const { data, isLoading, error, mutate } = useSWR( + ["risks", search, page, pageSize, status, department, assigneeId], + () => + fetchRisks({ search, page, pageSize, status, department, assigneeId }), + { + revalidateOnFocus: true, + revalidateOnReconnect: true, + revalidateOnMount: true, + revalidateIfStale: true, + } + ); + + return { data: data || [], isLoading, error, mutate }; +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx index 4181aff8f3..249725e8e6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx @@ -1,198 +1,22 @@ -import { auth } from "@/auth"; -import { - type RiskRegisterType, - columns, -} from "@/components/tables/risk-register/columns"; -import { DataTable } from "@/components/tables/risk-register/data-table"; -import { - NoResults, - NoRisks, -} from "@/components/tables/risk-register/empty-states"; -import { FilterToolbar } from "@/components/tables/risk-register/filter-toolbar"; -import { Loading } from "@/components/tables/risk-register/loading"; -import { getServerColumnHeaders } from "@/components/tables/risk-register/server-columns"; -import { getI18n } from "@/locales/server"; -import { type Departments, type RiskStatus, db } from "@bubba/db"; +import { RiskRegisterTable } from "./RiskRegisterTable"; import type { Metadata } from "next"; +import { getI18n } from "@/locales/server"; import { setStaticParamsLocale } from "next-international/server"; -import { unstable_cache } from "next/cache"; -import { redirect } from "next/navigation"; - -export default async function RiskRegisterPage({ - searchParams, -}: { - params: Promise<{ locale: string }>; - searchParams: Promise<{ - search?: string; - category?: string; - status?: string; - department?: string; - sort?: string; - page?: string; - per_page?: string; - }>; -}) { - const session = await auth(); - const organizationId = session?.user.organizationId; - const columnHeaders = await getServerColumnHeaders(); - - if (!organizationId) { - return redirect("/"); - } - - const { - search, - status, - department, - sort, - page = "1", - per_page = "10", - } = await searchParams; - - const [column, order] = sort?.split(":") ?? []; - const hasFilters = !!(search || status || department); - - const { risks: loadedRisks, total } = await getRisks({ - organizationId, - search, - status: status as RiskStatus, - department: department as Departments, - column, - order, - page: Number.parseInt(page), - per_page: Number.parseInt(per_page), - }); - - const users = await db.user.findMany({ - where: { - organizationId, - Risk: { - some: {}, - }, - }, - select: { - id: true, - name: true, - }, - }); - - if (loadedRisks.length === 0 && !hasFilters) { - return ( -
- - - -
- ); - } - - return ( -
- - {loadedRisks.length > 0 ? ( - - ) : ( - - )} -
- ); +export default function RiskRegisterPage() { + return ; } -const getRisks = unstable_cache( - async function risks({ - organizationId, - search, - status, - department, - column, - order, - page = 1, - per_page = 10, - }: { - organizationId: string; - search?: string; - status?: RiskStatus; - department?: Departments; - column?: string; - order?: string; - page?: number; - per_page?: number; - }) { - const skip = (page - 1) * per_page; - - const [risks, total] = await Promise.all([ - db.risk.findMany({ - where: { - organizationId, - AND: [ - search - ? { - OR: [ - { title: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - ], - } - : {}, - status ? { status } : {}, - department ? { department } : {}, - ], - }, - orderBy: column - ? { - [column]: order === "asc" ? "asc" : "desc", - } - : undefined, - skip, - take: per_page, - include: { - owner: { - select: { - name: true, - image: true, - }, - }, - }, - }), - db.risk.count({ - where: { - organizationId, - AND: [ - search - ? { - OR: [ - { title: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - ], - } - : {}, - status ? { status } : {}, - department ? { department } : {}, - ], - }, - }), - ]); - - return { risks, total }; - }, - ["risks-cache"], -); - export async function generateMetadata({ - params, + params, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale: string }>; }): Promise { - const { locale } = await params; - setStaticParamsLocale(locale); - const t = await getI18n(); + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); - return { - title: t("sub_pages.risk.register"), - }; + return { + title: t("sub_pages.risk.register"), + }; } diff --git a/apps/app/src/components/forms/risks/create-risk-form.tsx b/apps/app/src/components/forms/risks/create-risk-form.tsx index 32a384870d..7ef8cc9c0c 100644 --- a/apps/app/src/components/forms/risks/create-risk-form.tsx +++ b/apps/app/src/components/forms/risks/create-risk-form.tsx @@ -1,284 +1,307 @@ "use client"; -import { getOrganizationUsersAction } from "@/actions/organization/get-organization-users-action"; import { createRiskAction } from "@/actions/risk/create-risk-action"; import { createRiskSchema } from "@/actions/schema"; +import { useOrganizationAdmins } from "@/app/[locale]/(app)/(dashboard)/evidence/[id]/hooks/useOrganizationAdmins"; +import { useRisks } from "@/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks"; import { SelectUser } from "@/components/select-user"; import { useI18n } from "@/locales/client"; import { Departments, RiskCategory } from "@bubba/db"; +import type { RiskStatus } from "@bubba/db"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, } from "@bubba/ui/accordion"; import { Button } from "@bubba/ui/button"; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@bubba/ui/form"; import { Input } from "@bubba/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@bubba/ui/select"; import { Textarea } from "@bubba/ui/textarea"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRightIcon } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { useQueryState } from "nuqs"; -import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import type { z } from "zod"; +import { mutate } from "swr"; interface User { - id: string; - image?: string | null; - name: string | null; + id: string; + image?: string | null; + name: string | null; } export function CreateRisk() { - const t = useI18n(); + const t = useI18n(); - const [users, setUsers] = useState([]); - const [isLoadingUsers, setIsLoadingUsers] = useState(true); - const [_, setCreateRiskSheet] = useQueryState("create-risk-sheet"); + // Get the same query parameters as the table + const [search] = useQueryState("search"); + const [page] = useQueryState("page", { + defaultValue: 1, + parse: Number.parseInt, + }); + const [pageSize] = useQueryState("pageSize", { + defaultValue: 10, + parse: Number, + }); + const [status] = useQueryState("status", { + defaultValue: null, + parse: (value) => value as RiskStatus | null, + }); + const [department] = useQueryState("department", { + defaultValue: null, + parse: (value) => value as Departments | null, + }); + const [assigneeId] = useQueryState("assigneeId", { + defaultValue: null, + parse: (value) => value, + }); - useEffect(() => { - async function loadUsers() { - const result = await getOrganizationUsersAction(); - if (result?.data?.success && result?.data?.data) { - setUsers(result.data.data); - } - setIsLoadingUsers(false); - } + const { mutate: mutateRisks } = useRisks({ + search: search || "", + page: Number(page), + pageSize: Number(pageSize), + status, + department, + assigneeId, + }); - loadUsers(); - }, []); + const { data: admins, isLoading: isLoadingAdmins } = useOrganizationAdmins(); + const [_, setCreateRiskSheet] = useQueryState("create-risk-sheet"); - const createRisk = useAction(createRiskAction, { - onSuccess: () => { - toast.success(t("risk.form.create_risk_success")); - setCreateRiskSheet(null); - }, - onError: () => { - toast.error(t("risk.form.create_risk_error")); - }, - }); + const createRisk = useAction(createRiskAction, { + onSuccess: async () => { + toast.success(t("risk.form.create_risk_success")); + setCreateRiskSheet(null); + // Force invalidate and revalidate the risks list + await mutateRisks(); + }, + onError: () => { + toast.error(t("risk.form.create_risk_error")); + }, + }); - const form = useForm>({ - resolver: zodResolver(createRiskSchema), - defaultValues: { - title: "", - description: "", - category: RiskCategory.operations, - department: Departments.admin, - }, - }); + const form = useForm>({ + resolver: zodResolver(createRiskSchema), + defaultValues: { + title: "", + description: "", + category: RiskCategory.operations, + department: Departments.admin, + }, + }); - const onSubmit = (data: z.infer) => { - createRisk.execute(data); - }; + const onSubmit = (data: z.infer) => { + createRisk.execute(data); + }; - return ( -
- -
-
- - - - {t("risk.form.risk_details")} - - -
- ( - - {t("risk.form.risk_title")} - - - - - - )} - /> - ( - - - {t("risk.form.risk_description")} - - -