From 4f2b95eb851150a23cee4fface529f8c623c4627 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 14 Mar 2025 14:51:08 -0400 Subject: [PATCH 1/8] Move stat cards to overview --- .../evidence/list/components/EvidenceList.tsx | 5 +---- .../evidence/list/components/EvidenceListUIStates.tsx | 9 +-------- .../evidence/overview/components/EvidenceOverview.tsx | 4 ++++ .../evidence/overview/components/EvidenceUIStates.tsx | 7 +++++++ 4 files changed, 13 insertions(+), 12 deletions(-) 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..9f12435441 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 @@ -37,10 +37,7 @@ export function EvidenceList() { return (
- {/* Evidence summary statistics cards */} - - -
+
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..43b0400dd7 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 @@ -13,15 +13,8 @@ export function EvidenceListSkeleton() { return (
- {/* Summary cards skeleton - matching the style in EvidenceSummaryCards */} -
- {["empty", "draft", "review", "uptodate"].map((type) => ( - - ))} -
- {/* Filter controls skeleton */} -
+
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 */} From 53073abb574411d0d97d0e358ea05acfad309833 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 14 Mar 2025 14:52:10 -0400 Subject: [PATCH 2/8] search bigger --- .../(dashboard)/evidence/list/components/EvidenceList.tsx | 4 +++- .../evidence/list/components/EvidenceListUIStates.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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 9f12435441..45613ca934 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 @@ -38,7 +38,9 @@ export function EvidenceList() { 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 43b0400dd7..742d0d74a1 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 @@ -15,7 +15,7 @@ export function EvidenceListSkeleton() {
{/* Filter controls skeleton */}
-
+
From 899a5336ca011209722f6f505b73e067df0eac83 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 14 Mar 2025 15:33:07 -0400 Subject: [PATCH 3/8] Enhance EvidenceList component with improved loading and search handling - Added stabilization logic to prevent flashing states during loading and searching. - Introduced a new `isSearching` state to manage search transitions effectively. - Updated `SearchInput` to handle search changes with proper state management and maintain focus during transitions. - Refactored rendering logic in `EvidenceList` to conditionally display loading skeletons and empty states based on search and loading conditions. - Improved context management in `useEvidenceTableContext` to track search state and ensure UI transitions are smooth. --- .../evidence/list/components/EvidenceList.tsx | 90 +++++++++++++++---- .../table/EvidenceFilters/SearchInput.tsx | 71 +++++++++++++-- .../list/hooks/useEvidenceTableContext.tsx | 56 +++++++++++- yarn.lock | 25 +++--- 4 files changed, 204 insertions(+), 38 deletions(-) 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 45613ca934..cdaa2dd45e 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,33 +1,75 @@ "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"; +import { SkeletonTable } from "./table/SkeletonTable"; +import { useEffect, useRef, useState } from "react"; export function EvidenceList() { const { evidenceTasks, isLoading, + isSearching, error, mutate, hasActiveFilters, clearFilters, pagination, + search, } = useEvidenceTable(); - // Also track the loading state of the stats - const { isLoading: isStatsLoading } = useEvidenceTasksStats(); + const hasDataRef = useRef(false); + const hasSearchRef = useRef(false); + // Add a stabilization delay to prevent flashing states + const [isStabilized, setIsStabilized] = useState(true); - // Show loading state if either the list or stats are loading - if (isLoading || isStatsLoading) { + // Keep track of whether we've ever had data + useEffect(() => { + if (evidenceTasks && evidenceTasks.length > 0) { + hasDataRef.current = true; + } + }, [evidenceTasks]); + + // Handle search state transitions + useEffect(() => { + if (search) { + hasSearchRef.current = true; + // When search changes, we're not stabilized + setIsStabilized(false); + } else if (hasSearchRef.current && !search) { + // If search was set but is now cleared, force a refresh + hasSearchRef.current = false; + setIsStabilized(false); + setTimeout(() => { + mutate(); + }, 0); + } + }, [search, mutate]); + + // Stabilize state after loading/searching completes + useEffect(() => { + if (isLoading || isSearching) { + // When loading or searching, we're not stabilized + setIsStabilized(false); + } else { + // Add a small delay before considering the state stabilized + // This prevents flashing of empty states + const timer = setTimeout(() => { + setIsStabilized(true); + }, 100); + return () => clearTimeout(timer); + } + }, [isLoading, isSearching]); + + // Show loading state if it's the initial load and not a search/filter update + if (isLoading && !isSearching && !hasDataRef.current) { return ; } @@ -35,6 +77,27 @@ export function EvidenceList() { return ; } + // Determine what to render in the table area + const renderTableContent = () => { + // Always show skeleton during active searches or when not stabilized + if (isSearching || isLoading || !isStabilized) { + return ; + } + + // Only show empty state when we're stabilized, not searching, not loading, and have no data + if ((!evidenceTasks || evidenceTasks.length === 0) && isStabilized) { + return ( + + ); + } + + // Show data table when we have data and are in a stable state + return ; + }; + return (
@@ -44,17 +107,10 @@ export function EvidenceList() {
- {evidenceTasks && evidenceTasks.length === 0 ? ( - - ) : ( -
- - {pagination && } -
- )} +
+ {renderTableContent()} + {pagination && !isSearching && isStabilized && } +
); } 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 index 4edcefed76..64a80b97d8 100644 --- 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 @@ -2,7 +2,7 @@ import { Input } from "@bubba/ui/input"; import { Search } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useDebounce } from "use-debounce"; import { useEvidenceTable } from "../../../hooks/useEvidenceTableContext"; @@ -13,29 +13,86 @@ interface SearchInputProps { export function SearchInput({ placeholder = "Search evidence...", }: SearchInputProps) { - const { search, setSearch, setPage } = useEvidenceTable(); + const { search, setSearch, setPage, isSearching, mutate } = + useEvidenceTable(); const [inputValue, setInputValue] = useState(search || ""); const [debouncedValue] = useDebounce(inputValue, 500); + const inputRef = useRef(null); + const isPendingRef = useRef(false); + const previousSearchRef = useRef(search); - // Update search query parameter when debounced value changes + // Handle search changes with proper state management useEffect(() => { - if (debouncedValue === "") { + // Only trigger a new search if the value has actually changed + if (debouncedValue === previousSearchRef.current) { + return; + } + + // Track that we're about to make a change + isPendingRef.current = true; + + // Update the previous search reference + previousSearchRef.current = debouncedValue || null; + + // Handle empty search differently + if (!debouncedValue || debouncedValue.trim() === "") { setSearch(null); + // Force a data refresh to ensure we exit loading state + setTimeout(() => { + mutate(); + isPendingRef.current = false; + }, 0); } else { setSearch(debouncedValue); + isPendingRef.current = false; + } + + // Always reset to first page on search change + setPage("1"); + }, [debouncedValue, setSearch, setPage, mutate]); + + // Maintain focus when search changes + useEffect(() => { + // If input has focus, keep it focused during search transitions + if (document.activeElement === inputRef.current && inputRef.current) { + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus(); + const length = inputRef.current.value.length; + inputRef.current.setSelectionRange(length, length); + } + }); + } + + // If search is complete and we're not pending, ensure we exit loading state + if (!isSearching && !isPendingRef.current && inputValue === "") { + mutate(); + } + }, [isSearching, inputValue, mutate]); + + // Handle input changes + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + + // Special case: immediately trigger search when clearing input + if (newValue === "" && inputValue !== "") { + setSearch(null); + mutate(); + setPage("1"); } - setPage("1"); // Reset to first page when searching - }, [debouncedValue, setSearch, setPage]); + }; return (
setInputValue(e.target.value)} + onChange={handleInputChange} />
); 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..50bf9908cf 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"; @@ -47,6 +55,7 @@ interface EvidenceTableContextType { } | undefined; isLoading: boolean; + isSearching: boolean; error: Error | undefined; // Derived data @@ -76,6 +85,10 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { 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); @@ -97,6 +110,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) => ({ @@ -156,7 +207,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { setPage("1"); // Reset to first page when clearing filters }; - // Create context value + // Create context value including the new isSearching property const contextValue = { // State search, @@ -183,6 +234,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { evidenceTasks, pagination, isLoading, + isSearching, error, // Derived data diff --git a/yarn.lock b/yarn.lock index 16993e44dd..692af278bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1041,14 +1041,14 @@ formdata-node "^4.3.2" node-fetch "^2.6.7" -"@bubba/analytics@packages/analytics", "@bubba/analytics@workspace:*": +"@bubba/analytics@^workspace:packages/analytics", "@bubba/analytics@workspace:*": version "workspace:packages/analytics" resolved "workspace:packages/analytics" dependencies: posthog-js "^1.215.3" posthog-node "^4.4.1" -"@bubba/db@packages/db", "@bubba/db@workspace:*": +"@bubba/db@^workspace:packages/db", "@bubba/db@workspace:*": version "workspace:packages/db" resolved "workspace:packages/db" devDependencies: @@ -1063,7 +1063,7 @@ "@prisma/extension-accelerate" "^1.2.2" prisma "^6.3.1" -"@bubba/email@packages/email": +"@bubba/email@^workspace:packages/email": version "workspace:packages/email" resolved "workspace:packages/email" devDependencies: @@ -1083,7 +1083,7 @@ react-email "3.0.4" responsive-react-email "^0.0.5" -"@bubba/integrations@packages/integrations": +"@bubba/integrations@^workspace:packages/integrations": version "workspace:packages/integrations" resolved "workspace:packages/integrations" devDependencies: @@ -1097,14 +1097,14 @@ sharp "^0.33.5" zod "^3.24.2" -"@bubba/kv@packages/kv": +"@bubba/kv@^workspace:packages/kv": version "workspace:packages/kv" resolved "workspace:packages/kv" dependencies: "@upstash/redis" "^1.34.2" server-only "0.0.1" -"@bubba/notifications@packages/notifications", "@bubba/notifications@workspace:*": +"@bubba/notifications@^workspace:packages/notifications", "@bubba/notifications@workspace:*": version "workspace:packages/notifications" resolved "workspace:packages/notifications" devDependencies: @@ -1113,11 +1113,11 @@ "@novu/node" "^2.0.1" nanoid "5.1.0" -"@bubba/tsconfig@packages/tsconfig", "@bubba/tsconfig@workspace:*": +"@bubba/tsconfig@^workspace:packages/tsconfig", "@bubba/tsconfig@workspace:*": version "workspace:packages/tsconfig" resolved "workspace:packages/tsconfig" -"@bubba/ui@packages/ui", "@bubba/ui@workspace:*": +"@bubba/ui@^workspace:packages/ui", "@bubba/ui@workspace:*": version "workspace:packages/ui" resolved "workspace:packages/ui" devDependencies: @@ -1181,7 +1181,7 @@ use-callback-ref "^1.3.3" vaul "^1.0.0" -"@bubba/utils@packages/utils", "@bubba/utils@workspace:*": +"@bubba/utils@^workspace:packages/utils", "@bubba/utils@workspace:*": version "workspace:packages/utils" resolved "workspace:packages/utils" devDependencies: @@ -6394,7 +6394,7 @@ commander@11.1.0: resolved "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz" integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== -"comp.ai@apps/app": +comp.ai@^workspace:apps/app: version "workspace:apps/app" resolved "workspace:apps/app" devDependencies: @@ -11015,7 +11015,7 @@ plist@^3.1.0: base64-js "^1.5.1" xmlbuilder "^15.1.1" -"portal@apps/portal": +portal@^workspace:apps/portal: version "workspace:apps/portal" resolved "workspace:apps/portal" devDependencies: @@ -13654,11 +13654,12 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -"web@apps/web": +web@^workspace:apps/web: version "workspace:apps/web" resolved "workspace:apps/web" devDependencies: "@bubba/analytics" "workspace:*" + devDependencies: "@trigger.dev/build" "^3.3.15" "@types/node" "^22.13.2" "@types/react" "^19.0.8" From 66bbd8e2c9377117b868728e99041d4ae5b39051 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 17 Mar 2025 11:34:13 -0400 Subject: [PATCH 4/8] turned evidence table into reusable table --- .../evidence/list/components/EvidenceList.tsx | 110 +------ .../list/components/EvidenceListUIStates.tsx | 34 +- .../table/EvidenceFilters/FilterDropdown.tsx | 207 ------------- .../EvidenceFilters/PaginationControls.tsx | 78 ----- .../table/EvidenceFilters/SearchInput.tsx | 99 ------ .../components/table/EvidenceListTable.tsx | 189 +++++------- .../list/components/table/SkeletonTable.tsx | 68 ---- .../table/components/AssigneeAvatar.tsx | 26 ++ .../table/components/filterCategories.tsx | 107 +++++++ .../table/components/filterConfigs.tsx | 31 ++ .../list/hooks/useEvidenceTableContext.tsx | 39 ++- .../components/ui/data-table/DataTable.tsx | 292 ++++++++++++++++++ .../ui/data-table/DataTableHeader.tsx | 62 ++++ .../ui/data-table/DataTablePagination.tsx | 85 +++++ .../ui/data-table/DataTableSkeleton.tsx | 47 +++ .../app/src/components/ui/data-table/index.ts | 3 + apps/app/src/hooks/useDebounce.ts | 12 + 17 files changed, 786 insertions(+), 703 deletions(-) delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/FilterDropdown.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/PaginationControls.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/SearchInput.tsx delete mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/SkeletonTable.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/AssigneeAvatar.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterCategories.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/components/filterConfigs.tsx create mode 100644 apps/app/src/components/ui/data-table/DataTable.tsx create mode 100644 apps/app/src/components/ui/data-table/DataTableHeader.tsx create mode 100644 apps/app/src/components/ui/data-table/DataTablePagination.tsx create mode 100644 apps/app/src/components/ui/data-table/DataTableSkeleton.tsx create mode 100644 apps/app/src/components/ui/data-table/index.ts create mode 100644 apps/app/src/hooks/useDebounce.ts 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 cdaa2dd45e..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,116 +1,10 @@ "use client"; import { useEvidenceTable } from "../hooks/useEvidenceTableContext"; -import { - EvidenceListEmpty, - EvidenceListError, - EvidenceListSkeleton, -} from "./EvidenceListUIStates"; -import { FilterDropdown, SearchInput } from "./table/EvidenceFilters"; -import { PaginationControls } from "./table/EvidenceFilters/PaginationControls"; import { EvidenceListTable } from "./table/EvidenceListTable"; -import { SkeletonTable } from "./table/SkeletonTable"; -import { useEffect, useRef, useState } from "react"; export function EvidenceList() { - const { - evidenceTasks, - isLoading, - isSearching, - error, - mutate, - hasActiveFilters, - clearFilters, - pagination, - search, - } = useEvidenceTable(); + const { evidenceTasks } = useEvidenceTable(); - const hasDataRef = useRef(false); - const hasSearchRef = useRef(false); - // Add a stabilization delay to prevent flashing states - const [isStabilized, setIsStabilized] = useState(true); - - // Keep track of whether we've ever had data - useEffect(() => { - if (evidenceTasks && evidenceTasks.length > 0) { - hasDataRef.current = true; - } - }, [evidenceTasks]); - - // Handle search state transitions - useEffect(() => { - if (search) { - hasSearchRef.current = true; - // When search changes, we're not stabilized - setIsStabilized(false); - } else if (hasSearchRef.current && !search) { - // If search was set but is now cleared, force a refresh - hasSearchRef.current = false; - setIsStabilized(false); - setTimeout(() => { - mutate(); - }, 0); - } - }, [search, mutate]); - - // Stabilize state after loading/searching completes - useEffect(() => { - if (isLoading || isSearching) { - // When loading or searching, we're not stabilized - setIsStabilized(false); - } else { - // Add a small delay before considering the state stabilized - // This prevents flashing of empty states - const timer = setTimeout(() => { - setIsStabilized(true); - }, 100); - return () => clearTimeout(timer); - } - }, [isLoading, isSearching]); - - // Show loading state if it's the initial load and not a search/filter update - if (isLoading && !isSearching && !hasDataRef.current) { - return ; - } - - if (error) { - return ; - } - - // Determine what to render in the table area - const renderTableContent = () => { - // Always show skeleton during active searches or when not stabilized - if (isSearching || isLoading || !isStabilized) { - return ; - } - - // Only show empty state when we're stabilized, not searching, not loading, and have no data - if ((!evidenceTasks || evidenceTasks.length === 0) && isStabilized) { - return ( - - ); - } - - // Show data table when we have data and are in a stable state - return ; - }; - - return ( -
-
-
- -
- -
- -
- {renderTableContent()} - {pagination && !isSearching && isStabilized && } -
-
- ); + 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 742d0d74a1..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,36 +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 ( -
-
- {/* 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 */ @@ -58,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/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 64a80b97d8..0000000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/evidence/list/components/table/EvidenceFilters/SearchInput.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { Input } from "@bubba/ui/input"; -import { Search } from "lucide-react"; -import { useEffect, useState, useRef } 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, isSearching, mutate } = - useEvidenceTable(); - const [inputValue, setInputValue] = useState(search || ""); - const [debouncedValue] = useDebounce(inputValue, 500); - const inputRef = useRef(null); - const isPendingRef = useRef(false); - const previousSearchRef = useRef(search); - - // Handle search changes with proper state management - useEffect(() => { - // Only trigger a new search if the value has actually changed - if (debouncedValue === previousSearchRef.current) { - return; - } - - // Track that we're about to make a change - isPendingRef.current = true; - - // Update the previous search reference - previousSearchRef.current = debouncedValue || null; - - // Handle empty search differently - if (!debouncedValue || debouncedValue.trim() === "") { - setSearch(null); - // Force a data refresh to ensure we exit loading state - setTimeout(() => { - mutate(); - isPendingRef.current = false; - }, 0); - } else { - setSearch(debouncedValue); - isPendingRef.current = false; - } - - // Always reset to first page on search change - setPage("1"); - }, [debouncedValue, setSearch, setPage, mutate]); - - // Maintain focus when search changes - useEffect(() => { - // If input has focus, keep it focused during search transitions - if (document.activeElement === inputRef.current && inputRef.current) { - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus(); - const length = inputRef.current.value.length; - inputRef.current.setSelectionRange(length, length); - } - }); - } - - // If search is complete and we're not pending, ensure we exit loading state - if (!isSearching && !isPendingRef.current && inputValue === "") { - mutate(); - } - }, [isSearching, inputValue, mutate]); - - // Handle input changes - const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setInputValue(newValue); - - // Special case: immediately trigger search when clearing input - if (newValue === "" && inputValue !== "") { - setSearch(null); - mutate(); - setPage("1"); - } - }; - - return ( -
- - -
- ); -} 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 50bf9908cf..547a48df81 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 @@ -15,6 +15,7 @@ import { useOrganizationEvidenceTasks } from "../../hooks/useEvidenceTasks"; import { ALL_DEPARTMENTS } from "../../constants"; import { ALL_FREQUENCIES } from "../../constants"; import type { EvidenceTaskRow } from "../components/table"; +import { useDebounce } from "../../../../../../../hooks/useDebounce"; interface Assignee { id: string; @@ -22,9 +23,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; @@ -32,9 +39,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; @@ -42,6 +50,7 @@ interface EvidenceTableContextType { setRelevance: (value: string | null) => void; setPage: (value: string) => void; setPageSize: (value: string) => void; + setFilters: (filters: Filter[]) => void; mutate: () => void; // Data @@ -73,8 +82,11 @@ 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(""); + const debouncedSearch = useDebounce(search, 300); + + // Query state for other filters const [status, setStatus] = useQueryState("status"); const [frequency, setFrequency] = useQueryState("frequency"); const [department, setDepartment] = useQueryState("department"); @@ -85,6 +97,14 @@ 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); @@ -100,7 +120,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { error, mutate, } = useOrganizationEvidenceTasks({ - search, + search: debouncedSearch, status: status as "published" | "draft" | null, frequency: frequency as any, department: department as any, @@ -116,7 +136,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { setIsSearching(true); } }, [ - search, + debouncedSearch, status, frequency, department, @@ -205,10 +225,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 including the new isSearching property - const contextValue = { + const contextValue: EvidenceTableContextType = { // State search, status, @@ -218,6 +239,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { relevance, page, pageSize, + filters, // Setters setSearch, @@ -228,6 +250,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { setRelevance, setPage, setPageSize, + setFilters, mutate, // Data diff --git a/apps/app/src/components/ui/data-table/DataTable.tsx b/apps/app/src/components/ui/data-table/DataTable.tsx new file mode 100644 index 0000000000..21380cc13c --- /dev/null +++ b/apps/app/src/components/ui/data-table/DataTable.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { + type Column, + flexRender, + getCoreRowModel, + useReactTable, + getSortedRowModel, + type SortingState, + type ColumnDef, +} from "@tanstack/react-table"; +import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; +import { cn } from "@bubba/ui/cn"; +import { useState } from "react"; +import { DataTableHeader } from "./DataTableHeader"; +import { DataTablePagination } from "./DataTablePagination"; +import { Input } from "@bubba/ui/input"; +import { Button } from "@bubba/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuCheckboxItem, +} from "@bubba/ui/dropdown-menu"; +import { Filter, Search, X } from "lucide-react"; +import { DataTableSkeleton } from "./DataTableSkeleton"; +import { Badge } from "@bubba/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; + +interface FilterItem { + label: string; + value: string; + checked: boolean; + onChange: (checked: boolean) => void; + icon?: React.ReactNode; +} + +interface FilterCategory { + label: string; + items: FilterItem[]; + maxHeight?: string; +} + +interface DataTableProps { + data: TData[]; + columns: ColumnDef[]; + onRowClick?: (row: TData) => void; + className?: string; + emptyMessage?: string; + isLoading?: boolean; + pagination?: { + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + search?: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + }; + filters?: { + categories: FilterCategory[]; + hasActiveFilters: boolean; + onClearFilters: () => void; + activeFilterCount?: number; + }; +} + +export function DataTable({ + data, + columns, + onRowClick, + className, + emptyMessage = "No data found.", + isLoading = false, + pagination, + onPageChange, + onPageSizeChange, + search, + filters, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableColumnResizing: true, + columnResizeMode: "onChange", + defaultColumn: { + minSize: 40, + size: 150, + }, + state: { + sorting, + }, + onSortingChange: setSorting, + }); + + return ( +
+ {(search || filters) && ( +
+ {search && ( +
+
+ + search.onChange(e.target.value)} + className="pl-8 pr-8" + /> + {search.value && ( + + )} +
+
+ )} + {filters && ( + + + + + +
+ {filters.categories.map((category, index) => ( +
+ {category.label} +
+ {category.items.map((item) => ( + + {item.icon ? ( +
+ {item.icon} + {item.label} +
+ ) : ( + item.label + )} +
+ ))} +
+
+ ))} +
+ + {filters.hasActiveFilters && ( +
+ + +
+ )} +
+
+ )} +
+ )} + +
+
+ + + + {isLoading ? ( + + + + + + ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick?.(row.original)} + > + {row.getVisibleCells().map((cell) => ( + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
{ + // Stop propagation to prevent row click when resizing + e.stopPropagation(); + }} + /> + + ))} + + )) + ) : ( + + + {emptyMessage} + + + )} + +
+
+
+ + {pagination && onPageChange && onPageSizeChange && ( + + )} +
+ ); +} diff --git a/apps/app/src/components/ui/data-table/DataTableHeader.tsx b/apps/app/src/components/ui/data-table/DataTableHeader.tsx new file mode 100644 index 0000000000..8fd5ca8c20 --- /dev/null +++ b/apps/app/src/components/ui/data-table/DataTableHeader.tsx @@ -0,0 +1,62 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { TableHead, TableHeader, TableRow } from "@bubba/ui/table"; +import { flexRender } from "@tanstack/react-table"; +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; +import { cn } from "@bubba/ui/cn"; + +interface DataTableHeaderProps { + table: Table; +} + +export function DataTableHeader({ table }: DataTableHeaderProps) { + return ( + + {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/components/ui/data-table/DataTablePagination.tsx b/apps/app/src/components/ui/data-table/DataTablePagination.tsx new file mode 100644 index 0000000000..4f8757ffda --- /dev/null +++ b/apps/app/src/components/ui/data-table/DataTablePagination.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface DataTablePaginationProps { + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +export function DataTablePagination({ + page, + pageSize, + totalCount, + totalPages, + hasNextPage, + hasPreviousPage, + onPageChange, + onPageSizeChange, +}: DataTablePaginationProps) { + const handlePageSizeChange = (value: string) => { + onPageSizeChange(Number(value)); + onPageChange(1); // Reset to first page when changing page size + }; + + return ( +
+
+ {totalCount} {totalCount === 1 ? "item" : "items"} +
+
+ + +
+ +
+ Page {page} of {totalPages} +
+ +
+
+
+ ); +} diff --git a/apps/app/src/components/ui/data-table/DataTableSkeleton.tsx b/apps/app/src/components/ui/data-table/DataTableSkeleton.tsx new file mode 100644 index 0000000000..d17857f0b5 --- /dev/null +++ b/apps/app/src/components/ui/data-table/DataTableSkeleton.tsx @@ -0,0 +1,47 @@ +import { Skeleton } from "@bubba/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@bubba/ui/table"; +import { cn } from "@bubba/ui/cn"; + +interface DataTableSkeletonProps { + columns?: number; + rows?: number; + className?: string; +} + +export function DataTableSkeleton({ + columns = 5, + rows = 5, + className, +}: DataTableSkeletonProps) { + return ( + + + + {Array.from({ length: columns }).map((_, i) => ( + + + + ))} + + + + {Array.from({ length: rows }).map((_, i) => ( + + {Array.from({ length: columns }).map((_, j) => ( + + + + ))} + + ))} + +
+ ); +} diff --git a/apps/app/src/components/ui/data-table/index.ts b/apps/app/src/components/ui/data-table/index.ts new file mode 100644 index 0000000000..f2510fb8e6 --- /dev/null +++ b/apps/app/src/components/ui/data-table/index.ts @@ -0,0 +1,3 @@ +export { DataTable } from "./DataTable"; +export { DataTableHeader } from "./DataTableHeader"; +export { DataTableSkeleton } from "./DataTableSkeleton"; diff --git a/apps/app/src/hooks/useDebounce.ts b/apps/app/src/hooks/useDebounce.ts new file mode 100644 index 0000000000..d39df3f066 --- /dev/null +++ b/apps/app/src/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} From 618fa8aaf75390276fb45d8aeaa637a00c750918 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 17 Mar 2025 12:02:47 -0400 Subject: [PATCH 5/8] policies table updated --- .../(overview)/components/PoliciesList.tsx | 79 +-------- .../components/table/PoliciesTable.tsx | 88 ++++++++++ .../(overview)/components/table/columns.tsx | 75 ++++++++ .../table/components/filterCategories.tsx | 64 +++++++ .../table/components/filterConfigs.tsx | 24 +++ .../table/hooks/usePoliciesTableContext.tsx | 164 ++++++++++++++++++ .../all/(overview)/components/table/types.ts | 13 ++ .../all/(overview)/components/table/utils.ts | 18 ++ .../all/(overview)/hooks/usePolicies.ts | 5 +- .../components/ui/data-table/DataTable.tsx | 15 ++ 10 files changed, 470 insertions(+), 75 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts 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..79c9750557 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + useRef, + useEffect, + type ReactNode, +} from "react"; +import { useQueryState } from "nuqs"; +import { usePolicies } from "../../../hooks/usePolicies"; +import { useDebounce } from "@/hooks/useDebounce"; + +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(""); + const debouncedSearch = useDebounce(search, 300); + + // 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: debouncedSearch, + status: status || undefined, + ownerId: ownerId || undefined, + page: currentPage, + pageSize: currentPageSize, + }); + + // Track when search params change + useEffect(() => { + if (initialLoadCompleted.current) { + setIsSearching(true); + } + }, [debouncedSearch, 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/components/ui/data-table/DataTable.tsx b/apps/app/src/components/ui/data-table/DataTable.tsx index 21380cc13c..009b49e8b4 100644 --- a/apps/app/src/components/ui/data-table/DataTable.tsx +++ b/apps/app/src/components/ui/data-table/DataTable.tsx @@ -71,6 +71,11 @@ interface DataTableProps { onClearFilters: () => void; activeFilterCount?: number; }; + ctaButton?: { + label: string; + onClick: () => void; + icon?: React.ReactNode; + }; } export function DataTable({ @@ -85,6 +90,7 @@ export function DataTable({ onPageSizeChange, search, filters, + ctaButton, }: DataTableProps) { const [sorting, setSorting] = useState([]); @@ -209,6 +215,15 @@ export function DataTable({ )} + {ctaButton && ( + + )}
)} From b5d3d51b8f3751a5b1aa64a4b4ee793398b86c44 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 17 Mar 2025 12:45:59 -0400 Subject: [PATCH 6/8] update employees table --- .../list/hooks/useEvidenceTableContext.tsx | 6 +- .../people/actions/get-employees.ts | 3 +- .../people/components/EmployeesList.tsx | 77 +----- .../components/table/EmployeesTable.tsx | 69 ++++++ .../people/components/table/columns.tsx | 63 +++++ .../table/components/filterCategories.tsx | 41 ++++ .../table/hooks/useEmployeesTableContext.tsx | 138 +++++++++++ .../people/components/table/types.ts | 34 +++ .../(dashboard)/people/hooks/useEmployees.ts | 22 +- .../(app)/(dashboard)/people/page.tsx | 39 ++- .../(app)/(dashboard)/people/types.ts | 44 ++++ .../table/hooks/usePoliciesTableContext.tsx | 6 +- .../components/sheets/invite-user-sheet.tsx | 229 +++++++++--------- .../components/tables/tests/empty-states.tsx | 92 ++++--- .../components/ui/data-table/DataTable.tsx | 28 ++- 15 files changed, 612 insertions(+), 279 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts 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 547a48df81..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 @@ -15,7 +15,6 @@ import { useOrganizationEvidenceTasks } from "../../hooks/useEvidenceTasks"; import { ALL_DEPARTMENTS } from "../../constants"; import { ALL_FREQUENCIES } from "../../constants"; import type { EvidenceTaskRow } from "../components/table"; -import { useDebounce } from "../../../../../../../hooks/useDebounce"; interface Assignee { id: string; @@ -84,7 +83,6 @@ const EvidenceTableContext = createContext< export function EvidenceTableProvider({ children }: { children: ReactNode }) { // Local state for search const [search, setSearch] = useState(""); - const debouncedSearch = useDebounce(search, 300); // Query state for other filters const [status, setStatus] = useQueryState("status"); @@ -120,7 +118,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { error, mutate, } = useOrganizationEvidenceTasks({ - search: debouncedSearch, + search, status: status as "published" | "draft" | null, frequency: frequency as any, department: department as any, @@ -136,7 +134,7 @@ export function EvidenceTableProvider({ children }: { children: ReactNode }) { setIsSearching(true); } }, [ - debouncedSearch, + search, status, frequency, department, 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/table/hooks/usePoliciesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx index 79c9750557..bcdfc1e17e 100644 --- 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 @@ -11,7 +11,6 @@ import { } from "react"; import { useQueryState } from "nuqs"; import { usePolicies } from "../../../hooks/usePolicies"; -import { useDebounce } from "@/hooks/useDebounce"; interface PoliciesTableContextType { // State @@ -48,7 +47,6 @@ const PoliciesTableContext = createContext< export function PoliciesTableProvider({ children }: { children: ReactNode }) { // Local state for search const [search, setSearch] = useState(""); - const debouncedSearch = useDebounce(search, 300); // Query state for other filters const [status, setStatus] = useQueryState("status"); @@ -67,7 +65,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { // Fetch data const { policies, total, isLoading } = usePolicies({ - search: debouncedSearch, + search, status: status || undefined, ownerId: ownerId || undefined, page: currentPage, @@ -79,7 +77,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { if (initialLoadCompleted.current) { setIsSearching(true); } - }, [debouncedSearch, status, ownerId, page, pageSize]); + }, [search, status, ownerId, page, pageSize]); // Track when loading changes useEffect(() => { diff --git a/apps/app/src/components/sheets/invite-user-sheet.tsx b/apps/app/src/components/sheets/invite-user-sheet.tsx index 733a12f19d..9f3ac2df40 100644 --- a/apps/app/src/components/sheets/invite-user-sheet.tsx +++ b/apps/app/src/components/sheets/invite-user-sheet.tsx @@ -6,19 +6,19 @@ import { Button } from "@bubba/ui/button"; import { Input } from "@bubba/ui/input"; import { Label } from "@bubba/ui/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@bubba/ui/select"; import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, } from "@bubba/ui/sheet"; import type { Departments } from "@prisma/client"; import { useQueryState } from "nuqs"; @@ -26,115 +26,120 @@ import { useState } from "react"; import { toast } from "sonner"; const DEPARTMENTS: Departments[] = [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms", + "none", + "admin", + "gov", + "hr", + "it", + "itsm", + "qms", ]; -export function InviteUserSheet() { - const t = useI18n(); - const [open, setOpen] = useQueryState("invite-user-sheet"); - const [email, setEmail] = useState(""); - const [department, setDepartment] = useState("none"); - const [name, setName] = useState(""); - const { addEmployee, isMutating } = useEmployees(); +export function EmployeeInviteSheet() { + const t = useI18n(); + const [open, setOpen] = useQueryState("invite-user-sheet"); + const [email, setEmail] = useState(""); + const [department, setDepartment] = useState("none"); + const [name, setName] = useState(""); + const { addEmployee, isMutating } = useEmployees({ + search: "", + role: "", + page: 1, + per_page: 10, + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); - try { - await addEmployee({ - name, - email: email.trim(), - department, - }); + try { + await addEmployee({ + name, + email: email.trim(), + department, + }); - toast.success(t("people.invite.success")); - setOpen(null); - } catch (error) { - toast.error(t("errors.unexpected")); - } - }; + toast.success(t("people.invite.success")); + setOpen(null); + } catch (error) { + toast.error(t("errors.unexpected")); + } + }; - return ( - setOpen(open ? "true" : null)} - > - -
- - {t("people.invite.title")} - - {t("people.invite.description")} - - + return ( + setOpen(open ? "true" : null)} + > + + + + {t("people.invite.title")} + + {t("people.invite.description")} + + -
-
- - setName(e.target.value)} - required - /> -
+
+
+ + setName(e.target.value)} + required + /> +
-
- - setEmail(e.target.value)} - required - /> -
+
+ + setEmail(e.target.value)} + required + /> +
-
- - -
-
+
+ + +
+
- - - - -
-
- ); + + + + +
+
+ ); } diff --git a/apps/app/src/components/tables/tests/empty-states.tsx b/apps/app/src/components/tables/tests/empty-states.tsx index 6c3eac90d3..98b1e2d252 100644 --- a/apps/app/src/components/tables/tests/empty-states.tsx +++ b/apps/app/src/components/tables/tests/empty-states.tsx @@ -1,62 +1,60 @@ "use client"; +import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet"; import { useI18n } from "@/locales/client"; import { Button } from "@bubba/ui/button"; import { CloudOff } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useQueryState } from "nuqs"; -import { InviteUserSheet } from "@/components/sheets/invite-user-sheet"; -import { Plus } from "lucide-react"; interface Props { - hasFilters?: boolean; + hasFilters?: boolean; } export function NoResults({ hasFilters }: Props) { - const router = useRouter(); - const t = useI18n(); - - return ( -
-
- -
-

- {t("tests.empty.no_results.title")} -

-

- {hasFilters - ? t("tests.empty.no_results.description_with_filters") - : t("tests.empty.no_results.description")} -

-
- - {hasFilters && ( - - )} -
-
- ); + const router = useRouter(); + const t = useI18n(); + + return ( +
+
+ +
+

+ {t("tests.empty.no_results.title")} +

+

+ {hasFilters + ? t("tests.empty.no_results.description_with_filters") + : t("tests.empty.no_results.description")} +

+
+ + {hasFilters && ( + + )} +
+
+ ); } export function NoTests() { - const t = useI18n(); - - return ( -
-
- -

- {t("tests.empty.no_tests.title")} -

-

- {t("tests.empty.no_tests.description")} -

-
- - -
- ); + const t = useI18n(); + + return ( +
+
+ +

+ {t("tests.empty.no_tests.title")} +

+

+ {t("tests.empty.no_tests.description")} +

+
+ + +
+ ); } diff --git a/apps/app/src/components/ui/data-table/DataTable.tsx b/apps/app/src/components/ui/data-table/DataTable.tsx index 009b49e8b4..bc62929b4f 100644 --- a/apps/app/src/components/ui/data-table/DataTable.tsx +++ b/apps/app/src/components/ui/data-table/DataTable.tsx @@ -11,7 +11,7 @@ import { } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; import { cn } from "@bubba/ui/cn"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { DataTableHeader } from "./DataTableHeader"; import { DataTablePagination } from "./DataTablePagination"; import { Input } from "@bubba/ui/input"; @@ -93,6 +93,18 @@ export function DataTable({ ctaButton, }: DataTableProps) { const [sorting, setSorting] = useState([]); + const [searchValue, setSearchValue] = useState(search?.value || ""); + + // Internal debounced search + useEffect(() => { + const timer = setTimeout(() => { + search?.onChange(searchValue); + }, 300); + + return () => { + clearTimeout(timer); + }; + }, [searchValue, search]); const table = useReactTable({ data, @@ -111,6 +123,10 @@ export function DataTable({ onSortingChange: setSorting, }); + // Only show pagination when we have valid data and total count + const showPagination = + pagination && data.length > 0 && pagination.totalCount > 0; + return (
{(search || filters) && ( @@ -121,16 +137,16 @@ export function DataTable({ search.onChange(e.target.value)} + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} className="pl-8 pr-8" /> - {search.value && ( + {searchValue && ( @@ -295,7 +311,7 @@ export function DataTable({
- {pagination && onPageChange && onPageSizeChange && ( + {showPagination && onPageChange && onPageSizeChange && ( Date: Mon, 17 Mar 2025 17:40:11 -0400 Subject: [PATCH 7/8] update risk table --- .../risk/register/RiskRegisterTable.tsx | 129 + .../risk/register/actions/getRisks.ts | 64 + .../components/table/RiskRegisterColumns.tsx | 56 + .../components/table/RiskRegisterFilters.tsx | 73 + .../risk/register/hooks/useRisks.ts | 61 + .../(app)/(dashboard)/risk/register/page.tsx | 200 +- .../forms/risks/create-risk-form.tsx | 515 ++-- apps/app/src/components/status.tsx | 15 +- apps/app/src/locales/en.ts | 2182 ++++++++--------- 9 files changed, 1763 insertions(+), 1532 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts 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")} - - -