+ {/* Summary cards skeleton */}
+
+ {["empty", "draft", "review", "uptodate"].map((type) => (
+
+ ))}
+
+
{/* Department Chart Skeleton */}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts
index 823417ea8c..cb9abdc714 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/actions/get-employees.ts
@@ -2,7 +2,8 @@
import { db } from "@bubba/db";
import { authActionClient } from "@/actions/safe-action";
-import { employeesInputSchema, appErrors } from "../types";
+import { employeesInputSchema } from "../types";
+import { appErrors } from "../types";
export const getEmployees = authActionClient
.schema(employeesInputSchema)
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx
index ae6fc93280..61e82e9076 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/EmployeesList.tsx
@@ -1,77 +1,12 @@
"use client";
-import { DataTable } from "@/components/tables/people/data-table";
-import {
- NoResults,
- NoEmployees,
-} from "@/components/tables/people/empty-states";
-import { FilterToolbar } from "@/components/tables/people/filter-toolbar";
-import { Loading } from "@/components/tables/people/loading";
-import { useEmployees } from "../hooks/useEmployees";
-import { useSearchParams } from "next/navigation";
-import type { PersonType } from "@/components/tables/people/columns";
-import { EmployeesListSkeleton } from "./EmployeesListSkeleton";
-
-interface EmployeesListProps {
- columnHeaders: {
- name: string;
- email: string;
- department: string;
- status: string;
- };
-}
-
-export function EmployeesList({ columnHeaders }: EmployeesListProps) {
- const searchParams = useSearchParams();
- const search = searchParams.get("search");
- const role = searchParams.get("role");
- const per_page = Number(searchParams.get("per_page")) || 10;
- const page = Number(searchParams.get("page")) || 1;
-
- const { employees, total, isLoading, error } = useEmployees();
-
- console.log({
- employees,
- });
-
- if (isLoading) {
- return ;
- }
-
- if (error) {
- return (
-
-
-
-
- );
- }
-
- const hasFilters = !!(search || role);
-
- if (employees.length === 0 && !hasFilters) {
- return (
-
-
-
-
-
- );
- }
+import { EmployeesTable } from "./table/EmployeesTable";
+import { EmployeesTableProvider } from "./table/hooks/useEmployeesTableContext";
+export function EmployeesList() {
return (
-
-
- {employees.length > 0 ? (
-
- ) : (
-
- )}
-
+
+
+
);
}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx
new file mode 100644
index 0000000000..0cb71c46c8
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/EmployeesTable.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet";
+import { DataTable } from "@/components/ui/data-table";
+import { Plus } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useQueryState } from "nuqs";
+import { getColumns } from "./columns";
+import { useEmployeesTable } from "./hooks/useEmployeesTableContext";
+
+export function EmployeesTable() {
+ const router = useRouter();
+ const [, setInviteSheetOpen] = useQueryState("invite-user-sheet");
+ const {
+ page,
+ setPage,
+ per_page,
+ setPerPage,
+ employees,
+ total,
+ search,
+ setSearch,
+ isLoading,
+ isSearching,
+ } = useEmployeesTable();
+
+ const handleRowClick = (employeeId: string) => {
+ router.push(`/people/${employeeId}`);
+ };
+
+ // Calculate pagination values only when total is defined
+ const pagination =
+ total !== undefined
+ ? {
+ page: Number(page),
+ pageSize: Number(per_page),
+ totalCount: total,
+ totalPages: Math.ceil(total / Number(per_page)),
+ hasNextPage: Number(page) * Number(per_page) < total,
+ hasPreviousPage: Number(page) > 1,
+ }
+ : undefined;
+
+ return (
+ <>
+ handleRowClick(row.id)}
+ emptyMessage="No employees found."
+ isLoading={isLoading || isSearching}
+ pagination={pagination}
+ onPageChange={(page) => setPage(page)}
+ onPageSizeChange={(pageSize) => setPerPage(pageSize)}
+ search={{
+ value: search || "",
+ onChange: setSearch,
+ placeholder: "Search employees...",
+ }}
+ ctaButton={{
+ label: "Add Employee",
+ onClick: () => setInviteSheetOpen("true"),
+ icon: ,
+ }}
+ />
+
+ >
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx
new file mode 100644
index 0000000000..6f5669899a
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/columns.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import {
+ EmployeeStatus,
+ getEmployeeStatusFromBoolean,
+} from "@/components/tables/people/employee-status";
+import type { Employee } from "@bubba/db";
+import { Avatar, AvatarFallback } from "@bubba/ui/avatar";
+import { Badge } from "@bubba/ui/badge";
+import type { ColumnDef } from "@tanstack/react-table";
+
+export function getColumns(
+ onRowClick: (id: string) => void,
+): ColumnDef[] {
+ return [
+ {
+ accessorKey: "name",
+ header: "Name",
+ cell: ({ row }) => {
+ const employee = row.original;
+ return (
+
+
+ {employee.name[0] || "?"}
+
+
+
{employee.name}
+
+ {employee.email}
+
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "department",
+ header: "Department",
+ cell: ({ row }) => {
+ const department = row.getValue("department") as string;
+ return (
+
+ {department.toUpperCase()}
+
+ );
+ },
+ },
+ {
+ accessorKey: "isActive",
+ header: "Status",
+ cell: ({ row }) => {
+ const isActive = row.original.isActive;
+ const status = getEmployeeStatusFromBoolean(isActive);
+
+ return (
+
+
+
+ );
+ },
+ },
+ ];
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx
new file mode 100644
index 0000000000..0ee84c7583
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/components/filterCategories.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import type { FilterCategory } from "../types";
+
+interface GetFilterCategoriesProps {
+ role: string;
+ setRole: (value: string | null) => void;
+ setPage: (value: string) => void;
+}
+
+export function getFilterCategories({
+ role,
+ setRole,
+ setPage,
+}: GetFilterCategoriesProps): FilterCategory[] {
+ return [
+ {
+ label: "Role",
+ items: [
+ {
+ label: "Admin",
+ value: "admin",
+ checked: role === "admin",
+ onChange: (checked) => {
+ setRole(checked ? "admin" : null);
+ setPage("1");
+ },
+ },
+ {
+ label: "Member",
+ value: "member",
+ checked: role === "member",
+ onChange: (checked) => {
+ setRole(checked ? "member" : null);
+ setPage("1");
+ },
+ },
+ ],
+ },
+ ];
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx
new file mode 100644
index 0000000000..735184a8f4
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/hooks/useEmployeesTableContext.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ useRef,
+ useEffect,
+ type ReactNode,
+} from "react";
+import { useQueryState } from "nuqs";
+import { useEmployees } from "../../../hooks/useEmployees";
+import type { AppError } from "../../../types";
+
+interface EmployeesTableContextType {
+ // State
+ search: string;
+ setSearch: (value: string) => void;
+ role: string;
+ setRole: (value: string) => void;
+ page: number;
+ setPage: (value: number) => void;
+ per_page: number;
+ setPerPage: (value: number) => void;
+ employees: any[];
+ total: number;
+ isLoading: boolean;
+ isSearching: boolean;
+ hasActiveFilters: boolean;
+
+ // Actions
+ clearFilters: () => void;
+}
+
+const EmployeesTableContext = createContext<
+ EmployeesTableContextType | undefined
+>(undefined);
+
+export function EmployeesTableProvider({ children }: { children: ReactNode }) {
+ // Local state for search with debounce
+ const [search, setSearch] = useState("");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const searchTimeoutRef = useRef(undefined);
+
+ // Query state for filters
+ const [role, setRole] = useQueryState("role");
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" });
+ const [per_page, setPerPage] = useQueryState("per_page", {
+ defaultValue: "10",
+ });
+
+ // Loading states
+ const [isSearching, setIsSearching] = useState(false);
+ const totalCountRef = useRef(0);
+
+ // Fetch data
+ const { employees, total, isLoading, error } = useEmployees({
+ search: debouncedSearch,
+ role: role ?? "",
+ page: Number(page),
+ per_page: Number(per_page),
+ });
+
+ // Update debounced search
+ useEffect(() => {
+ setIsSearching(true);
+ if (searchTimeoutRef.current) {
+ clearTimeout(searchTimeoutRef.current);
+ }
+ searchTimeoutRef.current = setTimeout(() => {
+ setDebouncedSearch(search);
+ setIsSearching(false);
+ }, 300);
+
+ return () => {
+ if (searchTimeoutRef.current) {
+ clearTimeout(searchTimeoutRef.current);
+ }
+ };
+ }, [search]);
+
+ // Cache total count
+ useEffect(() => {
+ if (total !== undefined) {
+ totalCountRef.current = total;
+ }
+ }, [total]);
+
+ // Clear filters
+ const clearFilters = async () => {
+ setSearch("");
+ setRole(null);
+ setPage("1");
+ setPerPage("10");
+ };
+
+ // Calculate if there are active filters
+ const hasActiveFilters = useMemo(() => {
+ return Boolean(role);
+ }, [role]);
+
+ const value = {
+ // State
+ search,
+ setSearch,
+ role: role ?? "",
+ setRole: (value: string) => setRole(value || null),
+ page: Number(page),
+ setPage: (value: number) => setPage(String(value)),
+ per_page: Number(per_page),
+ setPerPage: (value: number) => setPerPage(String(value)),
+ employees,
+ total: totalCountRef.current,
+ isLoading,
+ isSearching,
+ hasActiveFilters,
+
+ // Actions
+ clearFilters,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useEmployeesTable() {
+ const context = useContext(EmployeesTableContext);
+ if (!context) {
+ throw new Error(
+ "useEmployeesTable must be used within a EmployeesTableProvider",
+ );
+ }
+ return context;
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts
new file mode 100644
index 0000000000..16ef7e4f85
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/components/table/types.ts
@@ -0,0 +1,34 @@
+import type { User } from "@bubba/db";
+
+export interface EmployeesTableProps {
+ columnHeaders: {
+ name: string;
+ email: string;
+ department: string;
+ status: string;
+ };
+ users: Array<{
+ id: string;
+ name: string | null;
+ full_name: string | null;
+ email: string | null;
+ role: string;
+ onboarded: boolean;
+ emailVerified: Date | null;
+ image: string | null;
+ lastLogin: Date | null;
+ organizationId: string | null;
+ }>;
+}
+
+export interface FilterCategory {
+ label: string;
+ items: {
+ label: string;
+ value: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ icon?: React.ReactNode;
+ }[];
+ maxHeight?: string;
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts
index 2058bef107..ff517512c7 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/hooks/useEmployees.ts
@@ -35,14 +35,12 @@ async function fetchEmployees(
return result.data?.data as EmployeesResponse;
}
-export function useEmployees() {
- const searchParams = useSearchParams();
- const search = searchParams.get("search") || undefined;
- const role = searchParams.get("role") || undefined;
- const page = Number(searchParams.get("page")) || 1;
- const per_page = Number(searchParams.get("per_page")) || 10;
-
- /** SWR for fetching employees */
+export function useEmployees({
+ search = "",
+ role = "",
+ page = 1,
+ per_page = 10,
+}: EmployeesInput) {
const {
data,
error,
@@ -52,8 +50,8 @@ export function useEmployees() {
["employees", { search, role, page, per_page }],
() => fetchEmployees({ search, role, page, per_page }),
{
- revalidateOnFocus: true,
- revalidateOnReconnect: true,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
}
);
@@ -106,13 +104,11 @@ export function useEmployees() {
return {
employees: data?.employees ?? [],
- total: data?.total ?? 0,
+ total: data?.total,
isLoading,
isMutating, // <--- expose the mutation loader
error,
- /** Expose the revalidation if needed directly */
revalidateEmployees,
- /** Expose the create employee action */
addEmployee,
};
}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx
index f189e85333..99b308721d 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/page.tsx
@@ -1,5 +1,4 @@
import { auth } from "@/auth";
-import { getServerColumnHeaders } from "@/components/tables/people/server-columns";
import { getI18n } from "@/locales/server";
import type { Metadata } from "next";
import { setStaticParamsLocale } from "next-international/server";
@@ -7,36 +6,34 @@ import { redirect } from "next/navigation";
import { EmployeesList } from "./components/EmployeesList";
export default async function PeoplePage({
- params,
+ params,
}: {
- params: Promise<{ locale: string }>;
+ params: Promise<{ locale: string }>;
}) {
- const { locale } = await params;
- setStaticParamsLocale(locale);
+ const { locale } = await params;
+ setStaticParamsLocale(locale);
- const session = await auth();
- const organizationId = session?.user.organizationId;
+ const session = await auth();
+ const organizationId = session?.user.organizationId;
- if (!organizationId) {
- return redirect("/");
- }
+ if (!organizationId) {
+ return redirect("/");
+ }
- const columnHeaders = await getServerColumnHeaders();
-
- return ;
+ return ;
}
export async function generateMetadata({
- params,
+ params,
}: {
- params: Promise<{ locale: string }>;
+ params: Promise<{ locale: string }>;
}): Promise {
- const { locale } = await params;
+ const { locale } = await params;
- setStaticParamsLocale(locale);
- const t = await getI18n();
+ setStaticParamsLocale(locale);
+ const t = await getI18n();
- return {
- title: t("sidebar.people"),
- };
+ return {
+ title: t("sidebar.people"),
+ };
}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts
new file mode 100644
index 0000000000..b43dc872a0
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/people/types.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+export interface AppError {
+ code: string;
+ message: string;
+}
+
+export const appErrors = {
+ UNAUTHORIZED: {
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this resource",
+ },
+ UNEXPECTED_ERROR: {
+ code: "UNEXPECTED_ERROR",
+ message: "An unexpected error occurred",
+ },
+};
+
+export interface EmployeesInput {
+ search?: string;
+ role?: string;
+ page?: number;
+ per_page?: number;
+}
+
+export const employeesInputSchema = z.object({
+ search: z.string().optional(),
+ role: z.string().optional(),
+ page: z.number().optional(),
+ per_page: z.number().optional(),
+});
+
+export interface EmployeesResponse {
+ employees: any[];
+ total: number;
+}
+
+export interface Employee {
+ id: string;
+ name: string;
+ email: string;
+ department: string;
+ status: string;
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx
index 444c3b54fb..5bc0d540be 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/PoliciesList.tsx
@@ -1,7 +1,5 @@
"use client";
-import type { PolicyType } from "@/components/tables/policies/columns";
-import { DataTable } from "@/components/tables/policies/data-table";
import {
NoPolicies,
NoResults,
@@ -14,10 +12,10 @@ import { Button } from "@bubba/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card";
import { AlertTriangle } from "lucide-react";
import Link from "next/link";
-import { useSearchParams } from "next/navigation";
-import { usePolicies } from "../hooks/usePolicies";
-import { PoliciesListSkeleton } from "./PoliciesListSkeleton";
import type { User } from "next-auth";
+import { PoliciesListSkeleton } from "./PoliciesListSkeleton";
+import { PoliciesTable } from "./table/PoliciesTable";
+import { PoliciesTableProvider } from "./table/hooks/usePoliciesTableContext";
interface PoliciesListProps {
columnHeaders: {
@@ -30,73 +28,12 @@ interface PoliciesListProps {
export function PoliciesList({ columnHeaders, users }: PoliciesListProps) {
const t = useI18n();
- const searchParams = useSearchParams();
- const search = searchParams.get("search");
- const status = searchParams.get("status");
- const sort = searchParams.get("sort");
- const pageSize = Number(searchParams.get("pageSize")) || 10;
- const page = Number(searchParams.get("page")) || 1;
-
- const { policies, total, isLoading, error } = usePolicies({
- search: search || undefined,
- status: status || undefined,
- sort: sort || undefined,
- page: page || 1,
- pageSize: pageSize || 10,
- });
-
- if (isLoading) {
- return ;
- }
-
- if (error) {
- return (
-
-
-
-
- {error.message || t("common.errors.unexpected_error")}
-
-
-
- );
- }
-
- const hasFilters = !!(search || status || sort);
-
- if (policies.length === 0 && !hasFilters) {
- return (
-
-
-
{t("policies.title")}
-
-
-
-
-
-
-
-
- );
- }
return (
-
-
-
- {policies.length > 0 ? (
-
- ) : (
-
- )}
-
+
+
+
);
}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx
new file mode 100644
index 0000000000..6c87823857
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/PoliciesTable.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { DataTable } from "@/components/ui/data-table";
+import { useRouter } from "next/navigation";
+import { Plus } from "lucide-react";
+import { getFilterCategories } from "./components/filterCategories";
+import { getColumns } from "./columns";
+import type { PoliciesTableProps } from "./types";
+import { usePoliciesTable } from "./hooks/usePoliciesTableContext";
+
+export function PoliciesTable({ users }: PoliciesTableProps) {
+ const router = useRouter();
+ const {
+ page,
+ setPage,
+ pageSize,
+ setPageSize,
+ policies,
+ total,
+ search,
+ setSearch,
+ status,
+ setStatus,
+ ownerId,
+ setOwnerId,
+ hasActiveFilters,
+ clearFilters,
+ isLoading,
+ isSearching,
+ } = usePoliciesTable();
+
+ const handleRowClick = (policyId: string) => {
+ router.push(`/policies/all/${policyId}`);
+ };
+
+ const activeFilterCount = [status, ownerId].filter(Boolean).length;
+
+ const filterCategories = getFilterCategories({
+ status,
+ setStatus,
+ ownerId,
+ setOwnerId,
+ users,
+ setPage,
+ });
+
+ // Calculate pagination values only when total is defined
+ const pagination =
+ total !== undefined
+ ? {
+ page: Number(page),
+ pageSize: Number(pageSize),
+ totalCount: total,
+ totalPages: Math.ceil(total / Number(pageSize)),
+ hasNextPage: Number(page) * Number(pageSize) < total,
+ hasPreviousPage: Number(page) > 1,
+ }
+ : undefined;
+
+ return (
+ handleRowClick(row.id)}
+ emptyMessage="No policies found."
+ isLoading={isLoading || isSearching}
+ pagination={pagination}
+ onPageChange={(page) => setPage(page.toString())}
+ onPageSizeChange={(pageSize) => setPageSize(pageSize.toString())}
+ search={{
+ value: search || "",
+ onChange: setSearch,
+ placeholder: "Search policies...",
+ }}
+ filters={{
+ categories: filterCategories,
+ hasActiveFilters,
+ onClearFilters: clearFilters,
+ activeFilterCount,
+ }}
+ ctaButton={{
+ label: "Create Policy",
+ onClick: () => router.push("/policies/all/new"),
+ icon: ,
+ }}
+ />
+ );
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx
new file mode 100644
index 0000000000..0cb128ca71
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/columns.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import type { ColumnDef } from "@tanstack/react-table";
+import type { PolicyStatus } from "./types";
+import { formatStatus, getStatusStyle } from "./utils";
+
+interface PolicyRow {
+ id: string;
+ status: PolicyStatus;
+ createdAt: Date;
+ updatedAt: Date;
+ policy: {
+ id: string;
+ name: string;
+ description: string | null;
+ slug: string;
+ };
+}
+
+export function getColumns(
+ handleRowClick: (policyId: string) => void,
+): ColumnDef[] {
+ return [
+ {
+ id: "name",
+ header: "Name",
+ accessorKey: "policy.name",
+ cell: ({ row }) => {
+ const name = row.original.policy.name;
+ return (
+
+
+
+ );
+ },
+ },
+ {
+ id: "status",
+ header: "Status",
+ accessorKey: "status",
+ cell: ({ row }) => {
+ const status = row.original.status;
+ return (
+
+
+
{formatStatus(status)}
+
+ );
+ },
+ },
+ {
+ id: "updatedAt",
+ header: "Last Updated",
+ accessorKey: "updatedAt",
+ cell: ({ row }) => {
+ const date = new Date(row.original.updatedAt);
+ return (
+
+ {date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+ );
+ },
+ },
+ ];
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx
new file mode 100644
index 0000000000..ae8855fb53
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterCategories.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { STATUS_FILTERS } from "./filterConfigs";
+import type { User } from "next-auth";
+
+interface FilterCategoriesProps {
+ status: string | null;
+ setStatus: (status: string | null) => void;
+ ownerId: string | null;
+ setOwnerId: (ownerId: string | null) => void;
+ users: User[];
+ setPage: (page: string) => void;
+}
+
+export function getFilterCategories({
+ status,
+ setStatus,
+ ownerId,
+ setOwnerId,
+ users,
+ setPage,
+}: FilterCategoriesProps) {
+ return [
+ {
+ label: "Filter by Status",
+ items: STATUS_FILTERS.map((filter) => ({
+ ...filter,
+ checked: status === filter.value,
+ onChange: (checked: boolean) => {
+ setStatus(checked ? filter.value : null);
+ setPage("1");
+ },
+ })),
+ },
+ {
+ label: "Filter by Owner",
+ items: users.map((user) => ({
+ label: user.name || "Unknown",
+ value: user.id || "",
+ checked: ownerId === user.id,
+ onChange: (checked: boolean) => {
+ setOwnerId(checked ? user.id || null : null);
+ setPage("1");
+ },
+ icon: (
+
+ {user.image ? (
+

+ ) : (
+
+ {(user.name || "?").charAt(0)}
+
+ )}
+
+ ),
+ })),
+ maxHeight: "150px",
+ },
+ ];
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx
new file mode 100644
index 0000000000..7d1a7d631d
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/components/filterConfigs.tsx
@@ -0,0 +1,24 @@
+import { CheckCircle2, XCircle, Archive, AlertTriangle } from "lucide-react";
+
+export const STATUS_FILTERS = [
+ {
+ label: "Published",
+ value: "published",
+ icon: ,
+ },
+ {
+ label: "Draft",
+ value: "draft",
+ icon: ,
+ },
+ {
+ label: "Needs Review",
+ value: "needs_review",
+ icon: ,
+ },
+ {
+ label: "Archived",
+ value: "archived",
+ icon: ,
+ },
+] as const;
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx
new file mode 100644
index 0000000000..bcdfc1e17e
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ useRef,
+ useEffect,
+ type ReactNode,
+} from "react";
+import { useQueryState } from "nuqs";
+import { usePolicies } from "../../../hooks/usePolicies";
+
+interface PoliciesTableContextType {
+ // State
+ search: string;
+ status: string | null;
+ ownerId: string | null;
+ page: string;
+ pageSize: string;
+
+ // Setters
+ setSearch: (value: string) => void;
+ setStatus: (value: string | null) => void;
+ setOwnerId: (value: string | null) => void;
+ setPage: (value: string) => void;
+ setPageSize: (value: string) => void;
+
+ // Data
+ policies: any[] | undefined;
+ total: number | undefined;
+ isLoading: boolean;
+ isSearching: boolean;
+
+ // Derived data
+ hasActiveFilters: boolean;
+
+ // Actions
+ clearFilters: () => void;
+}
+
+const PoliciesTableContext = createContext<
+ PoliciesTableContextType | undefined
+>(undefined);
+
+export function PoliciesTableProvider({ children }: { children: ReactNode }) {
+ // Local state for search
+ const [search, setSearch] = useState("");
+
+ // Query state for other filters
+ const [status, setStatus] = useQueryState("status");
+ const [ownerId, setOwnerId] = useQueryState("ownerId");
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" });
+ const [pageSize, setPageSize] = useQueryState("pageSize", {
+ defaultValue: "10",
+ });
+
+ // Track if this is initial load or a search/filter update
+ const initialLoadCompleted = useRef(false);
+ const [isSearching, setIsSearching] = useState(false);
+
+ const currentPage = Number.parseInt(page, 10);
+ const currentPageSize = Number.parseInt(pageSize, 10);
+
+ // Fetch data
+ const { policies, total, isLoading } = usePolicies({
+ search,
+ status: status || undefined,
+ ownerId: ownerId || undefined,
+ page: currentPage,
+ pageSize: currentPageSize,
+ });
+
+ // Track when search params change
+ useEffect(() => {
+ if (initialLoadCompleted.current) {
+ setIsSearching(true);
+ }
+ }, [search, status, ownerId, page, pageSize]);
+
+ // Track when loading changes
+ useEffect(() => {
+ if (isLoading === false) {
+ // Small delay to ensure UI transitions properly
+ setTimeout(() => {
+ initialLoadCompleted.current = true;
+ setIsSearching(false);
+ }, 50);
+ }
+ }, [isLoading]);
+
+ // Additional safety reset for isSearching when data changes
+ useEffect(() => {
+ if (policies && isSearching) {
+ // If we have data, ensure isSearching is eventually set to false
+ const timer = setTimeout(() => {
+ setIsSearching(false);
+ }, 100);
+ return () => clearTimeout(timer);
+ }
+ }, [policies, isSearching]);
+
+ // Check if any filters are active
+ const hasActiveFilters = useMemo(() => {
+ return status !== null || ownerId !== null;
+ }, [status, ownerId]);
+
+ // Clear all filters
+ const clearFilters = () => {
+ setStatus(null);
+ setOwnerId(null);
+ setPage("1"); // Reset to first page when clearing filters
+ setSearch(""); // Clear search
+ };
+
+ const contextValue: PoliciesTableContextType = {
+ // State
+ search,
+ status,
+ ownerId,
+ page,
+ pageSize,
+
+ // Setters
+ setSearch,
+ setStatus,
+ setOwnerId,
+ setPage,
+ setPageSize,
+
+ // Data
+ policies,
+ total, // Use the total directly from the API
+ isLoading,
+ isSearching,
+
+ // Derived data
+ hasActiveFilters,
+
+ // Actions
+ clearFilters,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePoliciesTable() {
+ const context = useContext(PoliciesTableContext);
+
+ if (context === undefined) {
+ throw new Error(
+ "usePoliciesTable must be used within a PoliciesTableProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts
new file mode 100644
index 0000000000..920374f603
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/types.ts
@@ -0,0 +1,13 @@
+import type { User } from "next-auth";
+import type { ReactNode } from "react";
+
+export type PolicyStatus = "published" | "draft" | "needs_review" | "archived";
+
+export interface PoliciesTableProps {
+ users: User[];
+ ctaButton?: {
+ label: string;
+ onClick: () => void;
+ icon?: ReactNode;
+ };
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts
new file mode 100644
index 0000000000..4715b718f4
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/components/table/utils.ts
@@ -0,0 +1,18 @@
+import type { PolicyStatus } from "./types";
+
+export function getStatusStyle(status: PolicyStatus) {
+ switch (status) {
+ case "published":
+ return "bg-[#00DC73]";
+ case "draft":
+ return "bg-[#ffc107]";
+ case "needs_review":
+ return "bg-[#ff0000]";
+ case "archived":
+ return "bg-[#0ea5e9]";
+ }
+}
+
+export function formatStatus(status: PolicyStatus) {
+ return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, " ");
+}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts
index 35ee5c4078..4708df8ab7 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/(overview)/hooks/usePolicies.ts
@@ -1,10 +1,7 @@
"use client";
-import { useSearchParams } from "next/navigation";
import useSWR from "swr";
-
import { getPolicies } from "../actions/get-policies";
-
import type { AppError, PoliciesInput, PoliciesResponse } from "../types";
async function fetchPolicies(input: PoliciesInput): Promise {
@@ -53,7 +50,7 @@ export function usePolicies({
return {
policies: data?.policies ?? [],
- total: data?.total ?? 0,
+ total: data?.total,
isLoading,
error,
revalidatePolicies,
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx
new file mode 100644
index 0000000000..9be3e4a96d
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/RiskRegisterTable.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { DataTable } from "@/components/ui/data-table";
+import { useRisks } from "./hooks/useRisks";
+import { columns } from "./components/table/RiskRegisterColumns";
+import type { User, Risk, Departments } from "@bubba/db";
+import type { RiskStatus } from "@bubba/db";
+import { useState } from "react";
+import { useQueryState } from "nuqs";
+import { RiskRegisterFilters } from "./components/table/RiskRegisterFilters";
+import { useOrganizationAdmins } from "../../evidence/[id]/hooks/useOrganizationAdmins";
+import { CreateRiskSheet } from "@/components/sheets/create-risk-sheet";
+import { Plus } from "lucide-react";
+import { useI18n } from "@/locales/client";
+
+type RiskRegisterTableRow = Risk & { owner: User | null };
+
+export const RiskRegisterTable = () => {
+ const t = useI18n();
+ // State
+ const [search, setSearch] = useState("");
+ const [open, setOpen] = useQueryState("create-risk-sheet");
+
+ const [page, setPage] = useQueryState("page", {
+ defaultValue: 1,
+ parse: Number.parseInt,
+ });
+ const [pageSize, setPageSize] = useQueryState("pageSize", {
+ defaultValue: 10,
+ parse: Number,
+ });
+ const [status, setStatus] = useQueryState("status", {
+ defaultValue: null,
+ parse: (value) => value as RiskStatus | null,
+ });
+ const [department, setDepartment] = useQueryState(
+ "department",
+ {
+ defaultValue: null,
+ parse: (value) => value as Departments | null,
+ },
+ );
+ const [assigneeId, setAssigneeId] = useQueryState(
+ "assigneeId",
+ {
+ defaultValue: null,
+ parse: (value) => value,
+ },
+ );
+
+ const { data, isLoading } = useRisks({
+ search: search,
+ page: Number(page),
+ pageSize: Number(pageSize),
+ status,
+ department,
+ assigneeId,
+ });
+
+ const hasActiveFilters = Boolean(status || department || assigneeId);
+
+ const handleClearFilters = () => {
+ setStatus(null);
+ setDepartment(null);
+ setAssigneeId(null);
+ setPage(1);
+ };
+
+ const departments: Departments[] = [
+ "none",
+ "it",
+ "hr",
+ "admin",
+ "gov",
+ "itsm",
+ "qms",
+ ] as const;
+
+ const { data: admins } = useOrganizationAdmins();
+
+ const filterCategories = RiskRegisterFilters({
+ setPage: (newPage: number) => setPage(newPage),
+ departments: departments,
+ assignees: admins || [],
+ status,
+ setStatus,
+ department,
+ setDepartment,
+ assigneeId,
+ setAssigneeId,
+ });
+
+ return (
+ <>
+
+ columns={columns}
+ data={data}
+ isLoading={isLoading}
+ search={{
+ value: search,
+ onChange: setSearch,
+ }}
+ pagination={{
+ page: Number(page),
+ pageSize: Number(pageSize),
+ totalCount: data.length,
+ totalPages: Math.ceil(data.length / Number(pageSize)),
+ hasNextPage: Number(page) < Math.ceil(data.length / Number(pageSize)),
+ hasPreviousPage: Number(page) > 1,
+ }}
+ onPageChange={(newPage) => setPage(newPage)}
+ onPageSizeChange={(newPageSize) => setPageSize(newPageSize)}
+ filters={{
+ categories: filterCategories,
+ hasActiveFilters,
+ onClearFilters: handleClearFilters,
+ activeFilterCount: [status, department, assigneeId].filter(Boolean)
+ .length,
+ }}
+ ctaButton={{
+ label: t("risk.register.empty.create_risk"),
+ onClick: () => setOpen("true"),
+ icon: ,
+ }}
+ />
+
+ >
+ );
+};
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts
new file mode 100644
index 0000000000..f7c9fcdc30
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/actions/getRisks.ts
@@ -0,0 +1,64 @@
+"use server";
+
+import { authActionClient } from "@/actions/safe-action";
+import { db, Departments, RiskStatus, Prisma } from "@bubba/db";
+import { z } from "zod";
+
+export const getRisks = authActionClient
+ .schema(
+ z.object({
+ search: z.string().optional(),
+ page: z.number().optional().default(1),
+ pageSize: z.number().optional().default(10),
+ status: z.nativeEnum(RiskStatus).nullable().optional(),
+ department: z.nativeEnum(Departments).nullable().optional(),
+ assigneeId: z.string().nullable().optional(),
+ })
+ )
+ .metadata({
+ name: "get-risks",
+ track: {
+ event: "get-risks",
+ channel: "server",
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { search, page, pageSize, status, department, assigneeId } =
+ parsedInput;
+ const { user } = ctx;
+
+ if (!user.organizationId) {
+ return {
+ success: false,
+ error: "Unauthorized",
+ };
+ }
+
+ const where = {
+ organizationId: user.organizationId,
+ ...(search && {
+ title: {
+ contains: search,
+ mode: Prisma.QueryMode.insensitive,
+ },
+ }),
+ ...(status ? { status } : {}),
+ ...(department ? { department } : {}),
+ ...(assigneeId ? { ownerId: assigneeId } : {}),
+ };
+
+ const skip = (page - 1) * (pageSize ?? 10);
+
+ const risks = await db.risk.findMany({
+ where,
+ skip,
+ take: pageSize,
+ include: {
+ owner: true,
+ },
+ });
+
+ return {
+ data: risks,
+ };
+ });
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx
new file mode 100644
index 0000000000..2a4fdf67ca
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterColumns.tsx
@@ -0,0 +1,56 @@
+import type { Risk, User } from "@bubba/db";
+import type { ColumnDef } from "@tanstack/react-table";
+import { Badge } from "@bubba/ui/badge";
+import Link from "next/link";
+import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar";
+import { Status } from "@/components/status";
+
+export const columns: ColumnDef[] = [
+ {
+ header: "Risk",
+ accessorKey: "title",
+ cell: ({ row }) => {
+ return (
+ {row.original.title}
+ );
+ },
+ },
+ {
+ header: "Status",
+ accessorKey: "status",
+ cell: ({ row }) => {
+ return ;
+ },
+ },
+ {
+ header: "Department",
+ accessorKey: "department",
+ cell: ({ row }) => {
+ return (
+
+ {row.original.department}
+
+ );
+ },
+ },
+ {
+ header: "Assignee",
+ accessorKey: "assignee",
+ cell: ({ row }) => {
+ return (
+
+
+
+
+ {row.original.owner?.name?.charAt(0) || "?"}
+
+
+
{row.original.owner?.name}
+
+ );
+ },
+ },
+];
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx
new file mode 100644
index 0000000000..6fcf096418
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/components/table/RiskRegisterFilters.tsx
@@ -0,0 +1,73 @@
+import { AssigneeAvatar } from "../../../../evidence/list/components/table/components/AssigneeAvatar";
+import type { Departments, User } from "@bubba/db";
+import { RiskStatus } from "@bubba/db";
+import { Status } from "@/components/status";
+import type { Admin } from "../../../../evidence/[id]/hooks/useOrganizationAdmins";
+
+export const RiskRegisterFilters = ({
+ setPage,
+ departments,
+ assignees,
+ status,
+ setStatus,
+ department,
+ setDepartment,
+ assigneeId,
+ setAssigneeId,
+}: {
+ setPage: (page: number) => void;
+ departments: Departments[];
+ assignees: Admin[] | undefined;
+ status: RiskStatus | null;
+ setStatus: (status: RiskStatus | null) => void;
+ department: Departments | null;
+ setDepartment: (department: Departments | null) => void;
+ assigneeId: string | null;
+ setAssigneeId: (assigneeId: string | null) => void;
+}) => {
+ return [
+ {
+ label: "Filter by Status",
+ items: Object.values(RiskStatus).map((filter) => ({
+ label: filter
+ .split(" ")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" "),
+ icon: ,
+ value: filter,
+ checked: status === filter,
+ onChange: (checked: boolean) => {
+ setStatus(checked ? filter : null);
+ setPage(1);
+ },
+ })),
+ },
+ {
+ label: "Filter by Department",
+ items: departments.map((dept) => ({
+ label: dept.replace(/_/g, " ").toUpperCase(),
+ value: dept,
+ checked: department === dept,
+ onChange: (checked: boolean) => {
+ setDepartment(checked ? dept : null);
+ setPage(1);
+ },
+ })),
+ maxHeight: "150px",
+ },
+ {
+ label: "Filter by Assignee",
+ items: (assignees || []).map((assignee) => ({
+ label: assignee.name || "Unknown",
+ value: assignee.id,
+ checked: assigneeId === assignee.id,
+ onChange: (checked: boolean) => {
+ setAssigneeId(checked ? assignee.id : null);
+ setPage(1);
+ },
+ icon: ,
+ })),
+ maxHeight: "150px",
+ },
+ ];
+};
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts
new file mode 100644
index 0000000000..610fe539d2
--- /dev/null
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks.ts
@@ -0,0 +1,61 @@
+import useSWR from "swr";
+import { getRisks } from "../actions/getRisks";
+import type { Departments, RiskStatus, Risk, User } from "@bubba/db";
+
+const fetchRisks = async (input: {
+ search?: string;
+ page?: number;
+ pageSize?: number;
+ status?: RiskStatus | null;
+ department?: Departments | null;
+ assigneeId?: string | null;
+}) => {
+ const response = await getRisks(input);
+
+ if (!response) {
+ throw new Error("Failed to fetch risks");
+ }
+
+ if (response.serverError) {
+ throw new Error(response.serverError);
+ }
+
+ if (response.validationErrors) {
+ throw new Error(
+ response.validationErrors._errors?.join(", ") ||
+ "Validation error occurred"
+ );
+ }
+
+ return response.data?.data;
+};
+
+export const useRisks = ({
+ search = "",
+ page = 1,
+ pageSize = 10,
+ status,
+ department,
+ assigneeId,
+}: {
+ search?: string;
+ page?: number;
+ pageSize?: number;
+ status?: RiskStatus | null;
+ department?: Departments | null;
+ assigneeId?: string | null;
+}) => {
+ const { data, isLoading, error, mutate } = useSWR(
+ ["risks", search, page, pageSize, status, department, assigneeId],
+ () =>
+ fetchRisks({ search, page, pageSize, status, department, assigneeId }),
+ {
+ revalidateOnFocus: true,
+ revalidateOnReconnect: true,
+ revalidateOnMount: true,
+ revalidateIfStale: true,
+ }
+ );
+
+ return { data: data || [], isLoading, error, mutate };
+};
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx
index 4181aff8f3..249725e8e6 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/risk/register/page.tsx
@@ -1,198 +1,22 @@
-import { auth } from "@/auth";
-import {
- type RiskRegisterType,
- columns,
-} from "@/components/tables/risk-register/columns";
-import { DataTable } from "@/components/tables/risk-register/data-table";
-import {
- NoResults,
- NoRisks,
-} from "@/components/tables/risk-register/empty-states";
-import { FilterToolbar } from "@/components/tables/risk-register/filter-toolbar";
-import { Loading } from "@/components/tables/risk-register/loading";
-import { getServerColumnHeaders } from "@/components/tables/risk-register/server-columns";
-import { getI18n } from "@/locales/server";
-import { type Departments, type RiskStatus, db } from "@bubba/db";
+import { RiskRegisterTable } from "./RiskRegisterTable";
import type { Metadata } from "next";
+import { getI18n } from "@/locales/server";
import { setStaticParamsLocale } from "next-international/server";
-import { unstable_cache } from "next/cache";
-import { redirect } from "next/navigation";
-
-export default async function RiskRegisterPage({
- searchParams,
-}: {
- params: Promise<{ locale: string }>;
- searchParams: Promise<{
- search?: string;
- category?: string;
- status?: string;
- department?: string;
- sort?: string;
- page?: string;
- per_page?: string;
- }>;
-}) {
- const session = await auth();
- const organizationId = session?.user.organizationId;
- const columnHeaders = await getServerColumnHeaders();
-
- if (!organizationId) {
- return redirect("/");
- }
-
- const {
- search,
- status,
- department,
- sort,
- page = "1",
- per_page = "10",
- } = await searchParams;
-
- const [column, order] = sort?.split(":") ?? [];
- const hasFilters = !!(search || status || department);
-
- const { risks: loadedRisks, total } = await getRisks({
- organizationId,
- search,
- status: status as RiskStatus,
- department: department as Departments,
- column,
- order,
- page: Number.parseInt(page),
- per_page: Number.parseInt(per_page),
- });
-
- const users = await db.user.findMany({
- where: {
- organizationId,
- Risk: {
- some: {},
- },
- },
- select: {
- id: true,
- name: true,
- },
- });
-
- if (loadedRisks.length === 0 && !hasFilters) {
- return (
-
-
-
-
-
- );
- }
-
- return (
-
-
- {loadedRisks.length > 0 ? (
-
- ) : (
-
- )}
-
- );
+export default function RiskRegisterPage() {
+ return ;
}
-const getRisks = unstable_cache(
- async function risks({
- organizationId,
- search,
- status,
- department,
- column,
- order,
- page = 1,
- per_page = 10,
- }: {
- organizationId: string;
- search?: string;
- status?: RiskStatus;
- department?: Departments;
- column?: string;
- order?: string;
- page?: number;
- per_page?: number;
- }) {
- const skip = (page - 1) * per_page;
-
- const [risks, total] = await Promise.all([
- db.risk.findMany({
- where: {
- organizationId,
- AND: [
- search
- ? {
- OR: [
- { title: { contains: search, mode: "insensitive" } },
- { description: { contains: search, mode: "insensitive" } },
- ],
- }
- : {},
- status ? { status } : {},
- department ? { department } : {},
- ],
- },
- orderBy: column
- ? {
- [column]: order === "asc" ? "asc" : "desc",
- }
- : undefined,
- skip,
- take: per_page,
- include: {
- owner: {
- select: {
- name: true,
- image: true,
- },
- },
- },
- }),
- db.risk.count({
- where: {
- organizationId,
- AND: [
- search
- ? {
- OR: [
- { title: { contains: search, mode: "insensitive" } },
- { description: { contains: search, mode: "insensitive" } },
- ],
- }
- : {},
- status ? { status } : {},
- department ? { department } : {},
- ],
- },
- }),
- ]);
-
- return { risks, total };
- },
- ["risks-cache"],
-);
-
export async function generateMetadata({
- params,
+ params,
}: {
- params: Promise<{ locale: string }>;
+ params: Promise<{ locale: string }>;
}): Promise {
- const { locale } = await params;
- setStaticParamsLocale(locale);
- const t = await getI18n();
+ const { locale } = await params;
+ setStaticParamsLocale(locale);
+ const t = await getI18n();
- return {
- title: t("sub_pages.risk.register"),
- };
+ return {
+ title: t("sub_pages.risk.register"),
+ };
}
diff --git a/apps/app/src/components/forms/risks/create-risk-form.tsx b/apps/app/src/components/forms/risks/create-risk-form.tsx
index 32a384870d..7ef8cc9c0c 100644
--- a/apps/app/src/components/forms/risks/create-risk-form.tsx
+++ b/apps/app/src/components/forms/risks/create-risk-form.tsx
@@ -1,284 +1,307 @@
"use client";
-import { getOrganizationUsersAction } from "@/actions/organization/get-organization-users-action";
import { createRiskAction } from "@/actions/risk/create-risk-action";
import { createRiskSchema } from "@/actions/schema";
+import { useOrganizationAdmins } from "@/app/[locale]/(app)/(dashboard)/evidence/[id]/hooks/useOrganizationAdmins";
+import { useRisks } from "@/app/[locale]/(app)/(dashboard)/risk/register/hooks/useRisks";
import { SelectUser } from "@/components/select-user";
import { useI18n } from "@/locales/client";
import { Departments, RiskCategory } from "@bubba/db";
+import type { RiskStatus } from "@bubba/db";
import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
} from "@bubba/ui/accordion";
import { Button } from "@bubba/ui/button";
import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
} from "@bubba/ui/form";
import { Input } from "@bubba/ui/input";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@bubba/ui/select";
import { Textarea } from "@bubba/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightIcon } from "lucide-react";
import { useAction } from "next-safe-action/hooks";
import { useQueryState } from "nuqs";
-import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
+import { mutate } from "swr";
interface User {
- id: string;
- image?: string | null;
- name: string | null;
+ id: string;
+ image?: string | null;
+ name: string | null;
}
export function CreateRisk() {
- const t = useI18n();
+ const t = useI18n();
- const [users, setUsers] = useState([]);
- const [isLoadingUsers, setIsLoadingUsers] = useState(true);
- const [_, setCreateRiskSheet] = useQueryState("create-risk-sheet");
+ // Get the same query parameters as the table
+ const [search] = useQueryState("search");
+ const [page] = useQueryState("page", {
+ defaultValue: 1,
+ parse: Number.parseInt,
+ });
+ const [pageSize] = useQueryState("pageSize", {
+ defaultValue: 10,
+ parse: Number,
+ });
+ const [status] = useQueryState("status", {
+ defaultValue: null,
+ parse: (value) => value as RiskStatus | null,
+ });
+ const [department] = useQueryState("department", {
+ defaultValue: null,
+ parse: (value) => value as Departments | null,
+ });
+ const [assigneeId] = useQueryState("assigneeId", {
+ defaultValue: null,
+ parse: (value) => value,
+ });
- useEffect(() => {
- async function loadUsers() {
- const result = await getOrganizationUsersAction();
- if (result?.data?.success && result?.data?.data) {
- setUsers(result.data.data);
- }
- setIsLoadingUsers(false);
- }
+ const { mutate: mutateRisks } = useRisks({
+ search: search || "",
+ page: Number(page),
+ pageSize: Number(pageSize),
+ status,
+ department,
+ assigneeId,
+ });
- loadUsers();
- }, []);
+ const { data: admins, isLoading: isLoadingAdmins } = useOrganizationAdmins();
+ const [_, setCreateRiskSheet] = useQueryState("create-risk-sheet");
- const createRisk = useAction(createRiskAction, {
- onSuccess: () => {
- toast.success(t("risk.form.create_risk_success"));
- setCreateRiskSheet(null);
- },
- onError: () => {
- toast.error(t("risk.form.create_risk_error"));
- },
- });
+ const createRisk = useAction(createRiskAction, {
+ onSuccess: async () => {
+ toast.success(t("risk.form.create_risk_success"));
+ setCreateRiskSheet(null);
+ // Force invalidate and revalidate the risks list
+ await mutateRisks();
+ },
+ onError: () => {
+ toast.error(t("risk.form.create_risk_error"));
+ },
+ });
- const form = useForm>({
- resolver: zodResolver(createRiskSchema),
- defaultValues: {
- title: "",
- description: "",
- category: RiskCategory.operations,
- department: Departments.admin,
- },
- });
+ const form = useForm>({
+ resolver: zodResolver(createRiskSchema),
+ defaultValues: {
+ title: "",
+ description: "",
+ category: RiskCategory.operations,
+ department: Departments.admin,
+ },
+ });
- const onSubmit = (data: z.infer) => {
- createRisk.execute(data);
- };
+ const onSubmit = (data: z.infer) => {
+ createRisk.execute(data);
+ };
- return (
-
+
+ );
}
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)}
- >
-
-
+
+
+ );
}
diff --git a/apps/app/src/components/status.tsx b/apps/app/src/components/status.tsx
index 0188d658a0..62d832894e 100644
--- a/apps/app/src/components/status.tsx
+++ b/apps/app/src/components/status.tsx
@@ -1,20 +1,21 @@
import { useI18n } from "@/locales/client";
import { cn } from "@bubba/ui/cn";
-export const STATUS_TYPES = ["open", "pending", "closed"] as const;
+export const STATUS_TYPES = ["open", "pending", "closed", "archived"] as const;
-export type StatusType = Exclude<
- (typeof STATUS_TYPES)[number],
- "draft" | "published"
->;
+export type StatusType = (typeof STATUS_TYPES)[number];
const STATUS_COLORS: Record = {
open: "#ffc107",
pending: "#0ea5e9",
closed: "#00DC73",
+ archived: "#64748b",
} as const;
-export function Status({ status }: { status: StatusType }) {
+export function Status({
+ status,
+ noLabel,
+}: { status: StatusType; noLabel?: boolean }) {
const t = useI18n();
return (
@@ -23,7 +24,7 @@ export function Status({ status }: { status: StatusType }) {
className={cn("size-2.5")}
style={{ backgroundColor: STATUS_COLORS[status] ?? " " }}
/>
- {t(`common.status.${status}`)}
+ {!noLabel && t(`common.status.${status}`)}
);
}
diff --git a/apps/app/src/components/tables/people/empty-states.tsx b/apps/app/src/components/tables/people/empty-states.tsx
index 27beb58110..f8a8d61850 100644
--- a/apps/app/src/components/tables/people/empty-states.tsx
+++ b/apps/app/src/components/tables/people/empty-states.tsx
@@ -5,63 +5,63 @@ import { Button } from "@bubba/ui/button";
import { Users } 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";
+import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet";
interface Props {
- hasFilters?: boolean;
+ hasFilters?: boolean;
}
export function NoResults({ hasFilters }: Props) {
- const router = useRouter();
- const t = useI18n();
+ const router = useRouter();
+ const t = useI18n();
- return (
-
-
-
-
-
- {t("people.empty.no_results.title")}
-
-
- {hasFilters
- ? t("people.empty.no_results.description_with_filters")
- : t("people.empty.no_results.description")}
-
-
+ return (
+
+
+
+
+
+ {t("people.empty.no_results.title")}
+
+
+ {hasFilters
+ ? t("people.empty.no_results.description_with_filters")
+ : t("people.empty.no_results.description")}
+
+
- {hasFilters && (
-
- )}
-
-
- );
+ {hasFilters && (
+
+ )}
+
+
+ );
}
export function NoEmployees() {
- const t = useI18n();
- const [open, setOpen] = useQueryState("invite-user-sheet");
+ const t = useI18n();
+ const [open, setOpen] = useQueryState("invite-user-sheet");
- return (
-
-
-
-
- {t("people.empty.no_employees.title")}
-
-
- {t("people.empty.no_employees.description")}
-
-
-
+ return (
+
+
+
+
+ {t("people.empty.no_employees.title")}
+
+
+ {t("people.empty.no_employees.description")}
+
+
+
-
-
- );
+
+
+ );
}
diff --git a/apps/app/src/components/tables/people/filter-toolbar.tsx b/apps/app/src/components/tables/people/filter-toolbar.tsx
index 30d84b81f9..8dbec46ae5 100644
--- a/apps/app/src/components/tables/people/filter-toolbar.tsx
+++ b/apps/app/src/components/tables/people/filter-toolbar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { InviteUserSheet } from "@/components/sheets/invite-user-sheet";
+import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet";
import { useI18n } from "@/locales/client";
import { Button } from "@bubba/ui/button";
import { Input } from "@bubba/ui/input";
@@ -11,94 +11,94 @@ import { useCallback, useEffect, useState, useTransition } from "react";
import { useDebounce } from "use-debounce";
interface FilterToolbarProps {
- isEmpty?: boolean;
+ isEmpty?: boolean;
}
export function FilterToolbar({ isEmpty }: FilterToolbarProps) {
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const t = useI18n();
- const [open, setOpen] = useQueryState("invite-user-sheet");
- const [isPending, startTransition] = useTransition();
- const [inputValue, setInputValue] = useState(
- searchParams?.get("search") ?? "",
- );
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const t = useI18n();
+ const [open, setOpen] = useQueryState("invite-user-sheet");
+ const [isPending, startTransition] = useTransition();
+ const [inputValue, setInputValue] = useState(
+ searchParams?.get("search") ?? "",
+ );
- const createQueryString = useCallback(
- (params: Record
) => {
- const newSearchParams = new URLSearchParams(searchParams?.toString());
+ const createQueryString = useCallback(
+ (params: Record) => {
+ const newSearchParams = new URLSearchParams(searchParams?.toString());
- for (const [key, value] of Object.entries(params)) {
- if (value === null) {
- newSearchParams.delete(key);
- } else {
- newSearchParams.set(key, value);
- }
- }
+ for (const [key, value] of Object.entries(params)) {
+ if (value === null) {
+ newSearchParams.delete(key);
+ } else {
+ newSearchParams.set(key, value);
+ }
+ }
- return newSearchParams.toString();
- },
- [searchParams],
- );
+ return newSearchParams.toString();
+ },
+ [searchParams],
+ );
- const [debouncedValue] = useDebounce(inputValue, 300);
+ const [debouncedValue] = useDebounce(inputValue, 300);
- useEffect(() => {
- startTransition(() => {
- router.push(
- `${pathname}?${createQueryString({
- search: debouncedValue || null,
- page: null,
- })}`,
- );
- });
- }, [debouncedValue, createQueryString, pathname, router]);
+ useEffect(() => {
+ startTransition(() => {
+ router.push(
+ `${pathname}?${createQueryString({
+ search: debouncedValue || null,
+ page: null,
+ })}`,
+ );
+ });
+ }, [debouncedValue, createQueryString, pathname, router]);
- return (
-
-
-
-
- setInputValue(e.target.value)}
- />
-
+ return (
+
+
+
+
+ setInputValue(e.target.value)}
+ />
+
-
-
-
-
+
+
+
+
-
- {inputValue && (
-
- )}
+
+ {inputValue && (
+
+ )}
-
-
+
+
-
-
- );
+
+
+ );
}
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
new file mode 100644
index 0000000000..bc62929b4f
--- /dev/null
+++ b/apps/app/src/components/ui/data-table/DataTable.tsx
@@ -0,0 +1,323 @@
+"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, useEffect } 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;
+ };
+ ctaButton?: {
+ label: string;
+ onClick: () => void;
+ icon?: React.ReactNode;
+ };
+}
+
+export function DataTable({
+ data,
+ columns,
+ onRowClick,
+ className,
+ emptyMessage = "No data found.",
+ isLoading = false,
+ pagination,
+ onPageChange,
+ onPageSizeChange,
+ search,
+ filters,
+ 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,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ enableColumnResizing: true,
+ columnResizeMode: "onChange",
+ defaultColumn: {
+ minSize: 40,
+ size: 150,
+ },
+ state: {
+ sorting,
+ },
+ 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) && (
+
+ {search && (
+
+
+
+ setSearchValue(e.target.value)}
+ className="pl-8 pr-8"
+ />
+ {searchValue && (
+
+ )}
+
+
+ )}
+ {filters && (
+
+
+
+
+
+
+ {filters.categories.map((category, index) => (
+
+
{category.label}
+
+ {category.items.map((item) => (
+
+ {item.icon ? (
+
+ {item.icon}
+ {item.label}
+
+ ) : (
+ item.label
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ {filters.hasActiveFilters && (
+
+
+
+
+ )}
+
+
+ )}
+ {ctaButton && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+ {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}
+
+
+ )}
+
+
+
+
+
+ {showPagination && 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;
+}
diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts
index dd25b312c0..82d00b69a8 100644
--- a/apps/app/src/locales/en.ts
+++ b/apps/app/src/locales/en.ts
@@ -1,1094 +1,1094 @@
export default {
- language: {
- title: "Languages",
- description: "Change the language used in the user interface.",
- placeholder: "Select language",
- },
- languages: {
- en: "English",
- es: "Spanish",
- fr: "French",
- no: "Norwegian",
- pt: "Portuguese",
- },
- common: {
- frequency: {
- daily: "Daily",
- weekly: "Weekly",
- monthly: "Monthly",
- quarterly: "Quarterly",
- yearly: "Yearly",
- },
- notifications: {
- inbox: "Inbox",
- archive: "Archive",
- archive_all: "Archive all",
- no_notifications: "No new notifications",
- },
- actions: {
- save: "Save",
- edit: "Edit",
- delete: "Delete",
- cancel: "Cancel",
- clear: "Clear",
- create: "Create",
- addNew: "Add New",
- send: "Send",
- return: "Return",
- success: "Success",
- error: "Error",
- next: "Next",
- complete: "Complete",
- },
- assignee: {
- label: "Assignee",
- placeholder: "Select assignee",
- },
- date: {
- pick: "Pick a date",
- due_date: "Due Date",
- },
- status: {
- open: "Open",
- pending: "Pending",
- closed: "Closed",
- archived: "Archived",
- compliant: "Compliant",
- non_compliant: "Non Compliant",
- not_started: "Not Started",
- in_progress: "In Progress",
- published: "Published",
- needs_review: "Needs Review",
- draft: "Draft",
- not_assessed: "Not Assessed",
- assessed: "Assessed",
- active: "Active",
- inactive: "Inactive",
- title: "Status",
- },
- filters: {
- clear: "Clear filters",
- search: "Search...",
- status: "Status",
- department: "Department",
- owner: {
- label: "Assignee",
- placeholder: "Filter by assignee",
- },
- },
- table: {
- title: "Title",
- status: "Status",
- assigned_to: "Assigned To",
- due_date: "Due Date",
- last_updated: "Last Updated",
- no_results: "No results found",
- },
- empty_states: {
- no_results: {
- title: "No results found",
- title_tasks: "No tasks found",
- title_risks: "No risks found",
- description: "Try another search, or adjusting the filters",
- description_filters: "Try another search, or adjusting the filters",
- description_no_tasks: "Create a task to get started",
- description_no_risks: "Create a risk to get started",
- },
- no_items: {
- title: "No items found",
- description: "Try adjusting your search or filters",
- },
- },
- pagination: {
- of: "of",
- items_per_page: "Items per page",
- rows_per_page: "Rows per page",
- page_x_of_y: "Page {{current}} of {{total}}",
- go_to_first_page: "Go to first page",
- go_to_previous_page: "Go to previous page",
- go_to_next_page: "Go to next page",
- go_to_last_page: "Go to last page",
- },
- comments: {
- title: "Comments",
- description: "Add a comment using the form below.",
- add: "New Comment",
- new: "New Comment",
- save: "Save Comment",
- success: "Comment added successfully",
- error: "Failed to add comment",
- placeholder: "Write your comment here...",
- empty: {
- title: "No comments yet",
- description: "Be the first to add a comment",
- },
- },
- upload: {
- fileUpload: {
- uploadingText: "Uploading...",
- uploadingFile: "Uploading file...",
- dropFileHere: "Drop file here",
- dropFileHereAlt: "Drop file here",
- releaseToUpload: "Release to upload",
- addFiles: "Add Files",
- uploadAdditionalEvidence: "Upload a file or document",
- dragDropOrClick: "Drag and drop or click to browse",
- dragDropOrClickToSelect: "Drag and drop or click to select file",
- maxFileSize: "Max file size: {size}MB",
- },
- fileUrl: {
- additionalLinks: "Additional Links",
- add: "Add",
- linksAdded: "{count} link{s} added",
- enterUrl: "Enter URL",
- addAnotherLink: "Add Another Link",
- saveLinks: "Save Links",
- urlBadge: "URL",
- copyLink: "Copy Link",
- openLink: "Open Link",
- deleteLink: "Delete Link",
- },
- fileCard: {
- preview: "Preview",
- filePreview: "File Preview: {fileName}",
- previewNotAvailable: "Preview not available for this file type",
- openFile: "Open File",
- deleteFile: "Delete File",
- deleteFileConfirmTitle: "Delete File",
- deleteFileConfirmDescription:
- "This action cannot be undone. The file will be permanently deleted.",
- },
- fileSection: {
- filesUploaded: "{count} files uploaded",
- },
- },
- attachments: {
- title: "Attachments",
- description: "Add a file by clicking 'Add Attachment'.",
- upload: "Upload attachment",
- upload_description:
- "Upload an attachment or add a link to an external resource.",
- drop: "Drop the files here",
- drop_description:
- "Drop files here or click to choose files from your device.",
- drop_files_description: "Files can be up to ",
- empty: {
- title: "No attachments",
- description: "Add a file by clicking 'Add Attachment'.",
- },
- toasts: {
- error: "Something went wrong, please try again.",
- error_uploading_files: "Cannot upload more than 1 file at a time",
- error_uploading_files_multiple: "Cannot upload more than 10 files",
- error_no_files_selected: "No files selected",
- error_file_rejected: "File {file} was rejected",
- error_failed_to_upload_files: "Failed to upload files",
- error_failed_to_upload_files_multiple: "Failed to upload files",
- error_failed_to_upload_files_single: "Failed to upload file",
- success_uploading_files: "Files uploaded successfully",
- success_uploading_files_multiple: "Files uploaded successfully",
- success_uploading_files_single: "File uploaded successfully",
- success_uploading_files_target: "Files uploaded",
- uploading_files: "Uploading {target}...",
- remove_file: "Remove file",
- },
- },
- edit: "Edit",
- errors: {
- unexpected_error: "An unexpected error occurred",
- },
- description: "Description",
- last_updated: "Last Updated",
- },
- header: {
- discord: {
- button: "Join us on Discord",
- },
- feedback: {
- button: "Feedback",
- title: "Thank you for your feedback!",
- description: "We will be back with you as soon as possible",
- placeholder: "Ideas to improve this page or issues you are experiencing.",
- success: "Thank you for your feedback!",
- error: "Error sending feedback - try again?",
- send: "Send Feedback",
- },
- },
- not_found: {
- title: "404 - Page not found",
- description: "The page you are looking for does not exist.",
- return: "Return to dashboard",
- },
- theme: {
- options: {
- light: "Light",
- dark: "Dark",
- system: "System",
- },
- },
- sidebar: {
- overview: "Overview",
- policies: "Policies",
- risk: "Risk Management",
- vendors: "Vendors",
- integrations: "Integrations",
- settings: "Settings",
- evidence: "Evidence Tasks",
- people: "People",
- tests: "Cloud Tests",
- },
- sub_pages: {
- evidence: {
- title: "Evidence",
- list: "Evidence List",
- overview: "Evidence Overview",
- },
- risk: {
- overview: "Risk Management",
- register: "Risk Register",
- risk_overview: "Risk Overview",
- risk_comments: "Risk Comments",
- tasks: {
- task_overview: "Task Overview",
- },
- },
- policies: {
- all: "All Policies",
- editor: "Policy Editor",
- policy_details: "Policy Details",
- },
- people: {
- all: "People",
- employee_details: "Employee Details",
- },
- settings: {
- members: "Team Members",
- },
- frameworks: {
- overview: "Frameworks",
- },
- tests: {
- overview: "Cloud Tests",
- test_details: "Test Details",
- },
- },
- auth: {
- title: "Automate SOC 2, ISO 27001 and GDPR compliance with AI.",
- description:
- "Create a free account or log in with an existing account to continue.",
- options: "More options",
- google: "Continue with Google",
- email: {
- description: "Enter your email address to continue.",
- placeholder: "Enter email address",
- button: "Continue with email",
- magic_link_sent: "Magic link sent",
- magic_link_description: "Check your inbox for a magic link.",
- magic_link_try_again: "Try again.",
- success: "Email sent - check your inbox!",
- error: "Error sending email - try again?",
- },
- terms:
- "By clicking continue, you acknowledge that you have read and agree to the Terms of Service and Privacy Policy.",
- },
- onboarding: {
- title: "Create an organization",
- setup: "Setup",
- description: "Tell us a bit about your organization.",
- fields: {
- fullName: {
- label: "Your Name",
- placeholder: "Your full name",
- },
- name: {
- label: "Organization Name",
- placeholder: "Your organization name",
- },
- subdomain: {
- label: "Subdomain",
- placeholder: "example",
- },
- website: {
- label: "Website",
- placeholder: "Your organization website",
- },
- },
- success: "Thanks, you're all set!",
- error: "Something went wrong, please try again.",
- check_availability: "Checking availability",
- available: "Available",
- unavailable: "Unavailable",
- },
- overview: {
- title: "Overview",
- framework_chart: {
- title: "Framework Progress",
- },
- requirement_chart: {
- title: "Compliance Status",
- },
- },
- policies: {
- dashboard: {
- title: "Dashboard",
- all: "All Policies",
- policy_status: "Policy by Status",
- policies_by_assignee: "Policies by Assignee",
- policies_by_framework: "Policies by Framework",
- sub_pages: {
- overview: "Overview",
- edit_policy: "Edit Policy",
- },
- },
- overview: {
- title: "Policy Overview",
- form: {
- update_policy: "Update Policy",
- update_policy_description: "Update the policy title or description.",
- update_policy_success: "Policy updated successfully",
- update_policy_error: "Failed to update policy",
- update_policy_title: "Policy Name",
- review_frequency: "Review Frequency",
- review_frequency_placeholder: "Select a review frequency",
- review_date: "Review Date",
- review_date_placeholder: "Select a review date",
- required_to_sign: "Required to be signed by employees",
- signature_required: "Require employees signature",
- signature_not_required: "Do not ask employees to sign",
- signature_requirement: "Signature Requirement",
- signature_requirement_placeholder: "Select signature requirement",
- },
- },
- new: {
- success: "Policy successfully created",
- error: "Failed to create policy",
- details: "Policy Details",
- title: "Enter a title for the policy",
- description: "Enter a description for the policy",
- },
- table: {
- name: "Policy Name",
- statuses: {
- draft: "Draft",
- published: "Published",
- archived: "Archived",
- },
- filters: {
- owner: {
- label: "Assignee",
- placeholder: "Filter by assignee",
- },
- },
- },
- filters: {
- search: "Search policies...",
- all: "All Policies",
- },
- status: {
- draft: "Draft",
- published: "Published",
- needs_review: "Needs Review",
- archived: "Archived",
- },
- policies: "policies",
- title: "Policies",
- create_new: "Create New Policy",
- search_placeholder: "Search policies...",
- status_filter: "Filter by status",
- all_statuses: "All statuses",
- no_policies_title: "No policies yet",
- no_policies_description: "Get started by creating your first policy",
- create_first: "Create first policy",
- no_description: "No description provided",
- last_updated: "Last updated: {{date}}",
- save: "Save",
- saving: "Saving...",
- saved_success: "Policy saved successfully",
- saved_error: "Failed to save policy",
- },
- evidence_tasks: {
- evidence_tasks: "Evidence Tasks",
- overview: "Overview",
- },
- risk: {
- risks: "risks",
- overview: "Overview",
- create: "Create New Risk",
- vendor: {
- title: "Vendor Management",
- dashboard: {
- title: "Vendor Dashboard",
- overview: "Vendor Overview",
- vendor_status: "Vendor Status",
- vendor_category: "Vendor Categories",
- vendors_by_assignee: "Vendors by Assignee",
- inherent_risk_description:
- "Initial risk level before any controls are applied",
- residual_risk_description:
- "Remaining risk level after controls are applied",
- },
- register: {
- title: "Vendor Register",
- table: {
- name: "Name",
- category: "Category",
- status: "Status",
- owner: "Owner",
- },
- },
- assessment: {
- title: "Vendor Assessment",
- update_success: "Vendor risk assessment updated successfully",
- update_error: "Failed to update vendor risk assessment",
- inherent_risk: "Inherent Risk",
- residual_risk: "Residual Risk",
- },
- form: {
- vendor_details: "Vendor Details",
- vendor_name: "Name",
- vendor_name_placeholder: "Enter vendor name",
- vendor_website: "Website",
- vendor_website_placeholder: "Enter vendor website",
- vendor_description: "Description",
- vendor_description_placeholder: "Enter vendor description",
- vendor_category: "Category",
- vendor_category_placeholder: "Select category",
- vendor_status: "Status",
- vendor_status_placeholder: "Select status",
- create_vendor_success: "Vendor created successfully",
- create_vendor_error: "Failed to create vendor",
- update_vendor: "Update Vendor",
- update_vendor_success: "Vendor updated successfully",
- update_vendor_error: "Failed to update vendor",
- add_comment: "Add Comment",
- },
- table: {
- name: "Name",
- category: "Category",
- status: "Status",
- owner: "Owner",
- },
- filters: {
- search_placeholder: "Search vendors...",
- status_placeholder: "Filter by status",
- category_placeholder: "Filter by category",
- owner_placeholder: "Filter by owner",
- },
- empty_states: {
- no_vendors: {
- title: "No vendors yet",
- description: "Get started by creating your first vendor",
- },
- no_results: {
- title: "No results found",
- description: "No vendors match your search",
- description_with_filters: "Try adjusting your filters",
- },
- },
- actions: {
- create: "Create Vendor",
- },
- status: {
- not_assessed: "Not Assessed",
- in_progress: "In Progress",
- assessed: "Assessed",
- },
- category: {
- cloud: "Cloud",
- infrastructure: "Infrastructure",
- software_as_a_service: "Software as a Service",
- finance: "Finance",
- marketing: "Marketing",
- sales: "Sales",
- hr: "HR",
- other: "Other",
- },
- risk_level: {
- low: "Low Risk",
- medium: "Medium Risk",
- high: "High Risk",
- unknown: "Unknown Risk",
- },
- },
- dashboard: {
- title: "Dashboard",
- overview: "Risk Overview",
- risk_status: "Risk Status",
- risks_by_department: "Risks by Department",
- risks_by_assignee: "Risks by Assignee",
- inherent_risk_description:
- "Inherent risk is calculated as likelihood * impact. Calculated before any controls are applied.",
- residual_risk_description:
- "Residual risk is calculated as likelihood * impact. This is the risk level after controls are applied.",
- risk_assessment_description: "Compare inherent and residual risk levels",
- },
- register: {
- title: "Risk Register",
- table: {
- risk: "Risk",
- },
- empty: {
- no_risks: {
- title: "Create a risk to get started",
- description:
- "Track and score risks, create and assign mitigation tasks for your team, and manage your risk register all in one simple interface.",
- },
- create_risk: "Create a risk",
- },
- },
- metrics: {
- probability: "Probability",
- impact: "Impact",
- inherentRisk: "Inherent Risk",
- residualRisk: "Residual Risk",
- },
- form: {
- update_inherent_risk: "Save Inherent Risk",
- update_inherent_risk_description:
- "Update the inherent risk of the risk. This is the risk level before any controls are applied.",
- update_inherent_risk_success: "Inherent risk updated successfully",
- update_inherent_risk_error: "Failed to update inherent risk",
- update_residual_risk: "Save Residual Risk",
- update_residual_risk_description:
- "Update the residual risk of the risk. This is the risk level after controls are applied.",
- update_residual_risk_success: "Residual risk updated successfully",
- update_residual_risk_error: "Failed to update residual risk",
- update_risk: "Update Risk",
- update_risk_description: "Update the risk title or description.",
- update_risk_success: "Risk updated successfully",
- update_risk_error: "Failed to update risk",
- create_risk_success: "Risk created successfully",
- create_risk_error: "Failed to create risk",
- risk_details: "Risk Details",
- risk_title: "Risk Title",
- risk_title_description: "Enter a name for the risk",
- risk_description: "Description",
- risk_description_description: "Enter a description for the risk",
- risk_category: "Category",
- risk_category_placeholder: "Select a category",
- risk_department: "Department",
- risk_department_placeholder: "Select a department",
- risk_status: "Risk Status",
- risk_status_placeholder: "Select a risk status",
- },
- tasks: {
- title: "Tasks",
- attachments: "Attachments",
- overview: "Task Overview",
- form: {
- title: "Task Details",
- task_title: "Task Title",
- status: "Task Status",
- status_placeholder: "Select a task status",
- task_title_description: "Enter a name for the task",
- description: "Description",
- description_description: "Enter a description for the task",
- due_date: "Due Date",
- due_date_description: "Select the due date for the task",
- success: "Task created successfully",
- error: "Failed to create task",
- },
- sheet: {
- title: "Create Task",
- update: "Update Task",
- update_description: "Update the task title or description.",
- },
- empty: {
- description_create:
- "Create a mitigation task for this risk, add a treatment plan, and assign it to a team member.",
- },
- },
- },
- people: {
- title: "People",
- details: {
- taskProgress: "Task Progress",
- tasks: "Tasks",
- noTasks: "No tasks assigned yet",
- },
- description: "Manage your team members and their roles.",
- filters: {
- search: "Search people...",
- role: "Filter by role",
- },
- actions: {
- invite: "Add Employee",
- clear: "Clear filters",
- },
- table: {
- name: "Name",
- email: "Email",
- department: "Department",
- externalId: "External ID",
- status: "Status",
- },
- empty: {
- no_employees: {
- title: "No employees yet",
- description: "Get started by inviting your first team member.",
- },
- no_results: {
- title: "No results found",
- description: "No employees match your search",
- description_with_filters: "Try adjusting your filters",
- },
- },
- invite: {
- title: "Add Employee",
- description: "Add an employee to your organization.",
- email: {
- label: "Email address",
- placeholder: "Enter email address",
- },
- role: {
- label: "Role",
- placeholder: "Select a role",
- },
- name: {
- label: "Name",
- placeholder: "Enter name",
- },
- department: {
- label: "Department",
- placeholder: "Select a department",
- },
- submit: "Add Employee",
- success: "Employee added successfully",
- error: "Failed to add employee",
- },
- },
- settings: {
- general: {
- title: "General",
- org_name: "Organization name",
- org_name_description:
- "This is your organizations visible name. You should use the legal name of your organization.",
- org_name_tip: "Please use 32 characters at maximum.",
- org_website: "Organization Website",
- org_website_description:
- "This is your organization's official website URL. Make sure to include the full URL with https://.",
- org_website_tip: "Please enter a valid URL including https://",
- org_website_error: "Error updating organization website",
- org_website_updated: "Organization website updated",
- org_delete: "Delete organization",
- org_delete_description:
- "Permanently remove your organization and all of its contents from the Comp AI platform. This action is not reversible - please continue with caution.",
- org_delete_alert_title: "Are you absolutely sure?",
- org_delete_alert_description:
- "This action cannot be undone. This will permanently delete your organization and remove your data from our servers.",
- org_delete_error: "Error deleting organization",
- org_delete_success: "Organization deleted",
- org_name_updated: "Organization name updated",
- org_name_error: "Error updating organization name",
- save_button: "Save",
- delete_button: "Delete",
- delete_confirm: "DELETE",
- delete_confirm_tip: "Type DELETE to confirm.",
- cancel_button: "Cancel",
- },
- members: {
- title: "Members",
- },
- api_keys: {
- title: "API Keys",
- description:
- "Manage API keys for programmatic access to your organization's data.",
- list_title: "API Keys",
- list_description:
- "API keys allow secure access to your organization's data via our API.",
- create: "New API Key",
- create_title: "New API Key",
- create_description:
- "Create a new API key for programmatic access to your organization's data.",
- created_title: "API Key Created",
- created_description:
- "Your API key has been created. Make sure to copy it now as you won't be able to see it again.",
- name: "Name",
- name_label: "Name",
- name_placeholder: "Enter a name for this API key",
- expiration: "Expiration",
- expiration_placeholder: "Select expiration",
- expires_label: "Expires",
- expires_placeholder: "Select expiration",
- expires_30days: "30 days",
- expires_90days: "90 days",
- expires_1year: "1 year",
- expires_never: "Never",
- thirty_days: "30 days",
- ninety_days: "90 days",
- one_year: "1 year",
- your_key: "Your API Key",
- api_key: "API Key",
- save_warning:
- "This key will only be shown once. Make sure to copy it now.",
- copied: "API key copied to clipboard",
- close_confirm:
- "Are you sure you want to close? You won't be able to see this API key again.",
- revoke_confirm:
- "Are you sure you want to revoke this API key? This action cannot be undone.",
- revoke_title: "Revoke API Key",
- revoke: "Revoke",
- created: "Created",
- expires: "Expires",
- last_used: "Last Used",
- actions: "Actions",
- never: "Never",
- never_used: "Never used",
- no_keys: "No API keys found. Create one to get started.",
- security_note:
- "API keys provide full access to your organization's data. Keep them secure and rotate them regularly.",
- fetch_error: "Failed to fetch API keys",
- create_error: "Failed to create API key",
- revoked_success: "API key revoked successfully",
- revoked_error: "Failed to revoke API key",
- done: "Done",
- },
- billing: {
- title: "Billing",
- },
- team: {
- tabs: {
- members: "Team Members",
- invite: "Invite Members",
- },
- members: {
- title: "Team Members",
- empty: {
- no_organization: {
- title: "No Organization",
- description: "You are not part of any organization",
- },
- no_members: {
- title: "No Members",
- description: "There are no active members in your organization",
- },
- },
- role: {
- owner: "Owner",
- admin: "Admin",
- member: "Member",
- viewer: "Viewer",
- },
- },
- invitations: {
- title: "Pending Invitations",
- description: "Users who have been invited but haven't accepted yet",
- empty: {
- no_organization: {
- title: "No Organization",
- description: "You are not part of any organization",
- },
- no_invitations: {
- title: "No Pending Invitations",
- description: "There are no pending invitations",
- },
- },
- invitation_sent: "Invitation sent",
- actions: {
- resend: "Resend Invite",
- sending: "Sending Invite",
- revoke: "Revoke",
- revoke_title: "Revoke Invitation",
- revoke_description_prefix:
- "Are you sure you want to revoke the invitation for",
- revoke_description_suffix: "This action cannot be undone.",
- },
- toast: {
- resend_success_prefix: "An invitation email has been sent to",
- resend_error: "Failed to send invitation",
- resend_unexpected:
- "An unexpected error occurred while sending the invitation",
- revoke_success_prefix: "Invitation to",
- revoke_success_suffix: "has been revoked",
- revoke_error: "Failed to revoke invitation",
- revoke_unexpected:
- "An unexpected error occurred while revoking the invitation",
- },
- },
- invite: {
- title: "Invite Team Member",
- description:
- "Send an invitation to a new team member to join your organization",
- form: {
- email: {
- label: "Email",
- placeholder: "member@example.com",
- error: "Please enter a valid email address",
- },
- role: {
- label: "Role",
- placeholder: "Select a role",
- error: "Please select a role",
- },
- department: {
- label: "Department",
- placeholder: "Select a department",
- error: "Please select a department",
- },
- departments: {
- none: "None",
- it: "IT",
- hr: "HR",
- admin: "Admin",
- gov: "Government",
- itsm: "ITSM",
- qms: "QMS",
- },
- },
- button: {
- send: "Send Invitation",
- sending: "Sending invitation...",
- sent: "Invitation Sent",
- },
- toast: {
- error: "Failed to send invitation",
- unexpected:
- "An unexpected error occurred while sending the invitation",
- },
- },
- member_actions: {
- actions: "Actions",
- change_role: "Change Role",
- remove_member: "Remove Member",
- remove_confirm: {
- title: "Remove Team Member",
- description_prefix: "Are you sure you want to remove",
- description_suffix: "This action cannot be undone.",
- },
- role_dialog: {
- title: "Change Role",
- description_prefix: "Update the role for",
- role_label: "Role",
- role_placeholder: "Select a role",
- role_descriptions: {
- admin: "Admins can manage team members and settings.",
- member:
- "Members can use all features but cannot manage team members.",
- viewer: "Viewers can only view content without making changes.",
- },
- cancel: "Cancel",
- update: "Update Role",
- },
- toast: {
- remove_success: "has been removed from the organization",
- remove_error: "Failed to remove member",
- remove_unexpected:
- "An unexpected error occurred while removing the member",
- update_role_success: "has had their role updated to",
- update_role_error: "Failed to update member role",
- update_role_unexpected:
- "An unexpected error occurred while updating the member's role",
- },
- },
- },
- },
- tests: {
- dashboard: {
- overview: "Overview",
- all: "All Tests",
- tests_by_assignee: "Tests by Assignee",
- passed: "Passed",
- failed: "Failed",
- severity_distribution: "Test Severity Distribution",
- },
- severity: {
- low: "Low",
- medium: "Medium",
- high: "High",
- critical: "Critical",
- },
- name: "Cloud Tests",
- title: "Cloud Tests",
- actions: {
- create: "Add Cloud Test",
- clear: "Clear filters",
- refresh: "Refresh",
- refresh_success: "Tests refreshed successfully",
- refresh_error: "Failed to refresh tests",
- },
- empty: {
- no_tests: {
- title: "No cloud tests yet",
- description: "Get started by creating your first cloud test.",
- },
- no_results: {
- title: "No results found",
- description: "No tests match your search",
- description_with_filters: "Try adjusting your filters",
- },
- },
- filters: {
- search: "Search tests...",
- role: "Filter by vendor",
- },
- register: {
- title: "Add Cloud Test",
- description: "Configure a new cloud compliance test.",
- submit: "Create Test",
- success: "Test created successfully",
- invalid_json: "Invalid JSON configuration provided",
+ language: {
+ title: "Languages",
+ description: "Change the language used in the user interface.",
+ placeholder: "Select language",
+ },
+ languages: {
+ en: "English",
+ es: "Spanish",
+ fr: "French",
+ no: "Norwegian",
+ pt: "Portuguese",
+ },
+ common: {
+ frequency: {
+ daily: "Daily",
+ weekly: "Weekly",
+ monthly: "Monthly",
+ quarterly: "Quarterly",
+ yearly: "Yearly",
+ },
+ notifications: {
+ inbox: "Inbox",
+ archive: "Archive",
+ archive_all: "Archive all",
+ no_notifications: "No new notifications",
+ },
+ actions: {
+ save: "Save",
+ edit: "Edit",
+ delete: "Delete",
+ cancel: "Cancel",
+ clear: "Clear",
+ create: "Create",
+ addNew: "Add New",
+ send: "Send",
+ return: "Return",
+ success: "Success",
+ error: "Error",
+ next: "Next",
+ complete: "Complete",
+ },
+ assignee: {
+ label: "Assignee",
+ placeholder: "Select assignee",
+ },
+ date: {
+ pick: "Pick a date",
+ due_date: "Due Date",
+ },
+ status: {
+ open: "Open",
+ pending: "Pending",
+ closed: "Closed",
+ archived: "Archived",
+ compliant: "Compliant",
+ non_compliant: "Non Compliant",
+ not_started: "Not Started",
+ in_progress: "In Progress",
+ published: "Published",
+ needs_review: "Needs Review",
+ draft: "Draft",
+ not_assessed: "Not Assessed",
+ assessed: "Assessed",
+ active: "Active",
+ inactive: "Inactive",
+ title: "Status",
+ },
+ filters: {
+ clear: "Clear filters",
+ search: "Search...",
+ status: "Status",
+ department: "Department",
+ owner: {
+ label: "Assignee",
+ placeholder: "Filter by assignee",
+ },
+ },
+ table: {
+ title: "Title",
+ status: "Status",
+ assigned_to: "Assigned To",
+ due_date: "Due Date",
+ last_updated: "Last Updated",
+ no_results: "No results found",
+ },
+ empty_states: {
+ no_results: {
+ title: "No results found",
+ title_tasks: "No tasks found",
+ title_risks: "No risks found",
+ description: "Try another search, or adjusting the filters",
+ description_filters: "Try another search, or adjusting the filters",
+ description_no_tasks: "Create a task to get started",
+ description_no_risks: "Create a risk to get started",
+ },
+ no_items: {
+ title: "No items found",
+ description: "Try adjusting your search or filters",
+ },
+ },
+ pagination: {
+ of: "of",
+ items_per_page: "Items per page",
+ rows_per_page: "Rows per page",
+ page_x_of_y: "Page {{current}} of {{total}}",
+ go_to_first_page: "Go to first page",
+ go_to_previous_page: "Go to previous page",
+ go_to_next_page: "Go to next page",
+ go_to_last_page: "Go to last page",
+ },
+ comments: {
+ title: "Comments",
+ description: "Add a comment using the form below.",
+ add: "New Comment",
+ new: "New Comment",
+ save: "Save Comment",
+ success: "Comment added successfully",
+ error: "Failed to add comment",
+ placeholder: "Write your comment here...",
+ empty: {
+ title: "No comments yet",
+ description: "Be the first to add a comment",
+ },
+ },
+ upload: {
+ fileUpload: {
+ uploadingText: "Uploading...",
+ uploadingFile: "Uploading file...",
+ dropFileHere: "Drop file here",
+ dropFileHereAlt: "Drop file here",
+ releaseToUpload: "Release to upload",
+ addFiles: "Add Files",
+ uploadAdditionalEvidence: "Upload a file or document",
+ dragDropOrClick: "Drag and drop or click to browse",
+ dragDropOrClickToSelect: "Drag and drop or click to select file",
+ maxFileSize: "Max file size: {size}MB",
+ },
+ fileUrl: {
+ additionalLinks: "Additional Links",
+ add: "Add",
+ linksAdded: "{count} link{s} added",
+ enterUrl: "Enter URL",
+ addAnotherLink: "Add Another Link",
+ saveLinks: "Save Links",
+ urlBadge: "URL",
+ copyLink: "Copy Link",
+ openLink: "Open Link",
+ deleteLink: "Delete Link",
+ },
+ fileCard: {
+ preview: "Preview",
+ filePreview: "File Preview: {fileName}",
+ previewNotAvailable: "Preview not available for this file type",
+ openFile: "Open File",
+ deleteFile: "Delete File",
+ deleteFileConfirmTitle: "Delete File",
+ deleteFileConfirmDescription:
+ "This action cannot be undone. The file will be permanently deleted.",
+ },
+ fileSection: {
+ filesUploaded: "{count} files uploaded",
+ },
+ },
+ attachments: {
+ title: "Attachments",
+ description: "Add a file by clicking 'Add Attachment'.",
+ upload: "Upload attachment",
+ upload_description:
+ "Upload an attachment or add a link to an external resource.",
+ drop: "Drop the files here",
+ drop_description:
+ "Drop files here or click to choose files from your device.",
+ drop_files_description: "Files can be up to ",
+ empty: {
+ title: "No attachments",
+ description: "Add a file by clicking 'Add Attachment'.",
+ },
+ toasts: {
+ error: "Something went wrong, please try again.",
+ error_uploading_files: "Cannot upload more than 1 file at a time",
+ error_uploading_files_multiple: "Cannot upload more than 10 files",
+ error_no_files_selected: "No files selected",
+ error_file_rejected: "File {file} was rejected",
+ error_failed_to_upload_files: "Failed to upload files",
+ error_failed_to_upload_files_multiple: "Failed to upload files",
+ error_failed_to_upload_files_single: "Failed to upload file",
+ success_uploading_files: "Files uploaded successfully",
+ success_uploading_files_multiple: "Files uploaded successfully",
+ success_uploading_files_single: "File uploaded successfully",
+ success_uploading_files_target: "Files uploaded",
+ uploading_files: "Uploading {target}...",
+ remove_file: "Remove file",
+ },
+ },
+ edit: "Edit",
+ errors: {
+ unexpected_error: "An unexpected error occurred",
+ },
+ description: "Description",
+ last_updated: "Last Updated",
+ },
+ header: {
+ discord: {
+ button: "Join us on Discord",
+ },
+ feedback: {
+ button: "Feedback",
+ title: "Thank you for your feedback!",
+ description: "We will be back with you as soon as possible",
+ placeholder: "Ideas to improve this page or issues you are experiencing.",
+ success: "Thank you for your feedback!",
+ error: "Error sending feedback - try again?",
+ send: "Send Feedback",
+ },
+ },
+ not_found: {
+ title: "404 - Page not found",
+ description: "The page you are looking for does not exist.",
+ return: "Return to dashboard",
+ },
+ theme: {
+ options: {
+ light: "Light",
+ dark: "Dark",
+ system: "System",
+ },
+ },
+ sidebar: {
+ overview: "Overview",
+ policies: "Policies",
+ risk: "Risk Management",
+ vendors: "Vendors",
+ integrations: "Integrations",
+ settings: "Settings",
+ evidence: "Evidence Tasks",
+ people: "People",
+ tests: "Cloud Tests",
+ },
+ sub_pages: {
+ evidence: {
+ title: "Evidence",
+ list: "Evidence List",
+ overview: "Evidence Overview",
+ },
+ risk: {
+ overview: "Risk Management",
+ register: "Risk Register",
+ risk_overview: "Risk Overview",
+ risk_comments: "Risk Comments",
+ tasks: {
+ task_overview: "Task Overview",
+ },
+ },
+ policies: {
+ all: "All Policies",
+ editor: "Policy Editor",
+ policy_details: "Policy Details",
+ },
+ people: {
+ all: "People",
+ employee_details: "Employee Details",
+ },
+ settings: {
+ members: "Team Members",
+ },
+ frameworks: {
+ overview: "Frameworks",
+ },
+ tests: {
+ overview: "Cloud Tests",
+ test_details: "Test Details",
+ },
+ },
+ auth: {
+ title: "Automate SOC 2, ISO 27001 and GDPR compliance with AI.",
+ description:
+ "Create a free account or log in with an existing account to continue.",
+ options: "More options",
+ google: "Continue with Google",
+ email: {
+ description: "Enter your email address to continue.",
+ placeholder: "Enter email address",
+ button: "Continue with email",
+ magic_link_sent: "Magic link sent",
+ magic_link_description: "Check your inbox for a magic link.",
+ magic_link_try_again: "Try again.",
+ success: "Email sent - check your inbox!",
+ error: "Error sending email - try again?",
+ },
+ terms:
+ "By clicking continue, you acknowledge that you have read and agree to the Terms of Service and Privacy Policy.",
+ },
+ onboarding: {
+ title: "Create an organization",
+ setup: "Setup",
+ description: "Tell us a bit about your organization.",
+ fields: {
+ fullName: {
+ label: "Your Name",
+ placeholder: "Your full name",
+ },
+ name: {
+ label: "Organization Name",
+ placeholder: "Your organization name",
+ },
+ subdomain: {
+ label: "Subdomain",
+ placeholder: "example",
+ },
+ website: {
+ label: "Website",
+ placeholder: "Your organization website",
+ },
+ },
+ success: "Thanks, you're all set!",
+ error: "Something went wrong, please try again.",
+ check_availability: "Checking availability",
+ available: "Available",
+ unavailable: "Unavailable",
+ },
+ overview: {
+ title: "Overview",
+ framework_chart: {
+ title: "Framework Progress",
+ },
+ requirement_chart: {
+ title: "Compliance Status",
+ },
+ },
+ policies: {
+ dashboard: {
+ title: "Dashboard",
+ all: "All Policies",
+ policy_status: "Policy by Status",
+ policies_by_assignee: "Policies by Assignee",
+ policies_by_framework: "Policies by Framework",
+ sub_pages: {
+ overview: "Overview",
+ edit_policy: "Edit Policy",
+ },
+ },
+ overview: {
+ title: "Policy Overview",
+ form: {
+ update_policy: "Update Policy",
+ update_policy_description: "Update the policy title or description.",
+ update_policy_success: "Policy updated successfully",
+ update_policy_error: "Failed to update policy",
+ update_policy_title: "Policy Name",
+ review_frequency: "Review Frequency",
+ review_frequency_placeholder: "Select a review frequency",
+ review_date: "Review Date",
+ review_date_placeholder: "Select a review date",
+ required_to_sign: "Required to be signed by employees",
+ signature_required: "Require employees signature",
+ signature_not_required: "Do not ask employees to sign",
+ signature_requirement: "Signature Requirement",
+ signature_requirement_placeholder: "Select signature requirement",
+ },
+ },
+ new: {
+ success: "Policy successfully created",
+ error: "Failed to create policy",
+ details: "Policy Details",
+ title: "Enter a title for the policy",
+ description: "Enter a description for the policy",
+ },
+ table: {
+ name: "Policy Name",
+ statuses: {
+ draft: "Draft",
+ published: "Published",
+ archived: "Archived",
+ },
+ filters: {
+ owner: {
+ label: "Assignee",
+ placeholder: "Filter by assignee",
+ },
+ },
+ },
+ filters: {
+ search: "Search policies...",
+ all: "All Policies",
+ },
+ status: {
+ draft: "Draft",
+ published: "Published",
+ needs_review: "Needs Review",
+ archived: "Archived",
+ },
+ policies: "policies",
+ title: "Policies",
+ create_new: "Create New Policy",
+ search_placeholder: "Search policies...",
+ status_filter: "Filter by status",
+ all_statuses: "All statuses",
+ no_policies_title: "No policies yet",
+ no_policies_description: "Get started by creating your first policy",
+ create_first: "Create first policy",
+ no_description: "No description provided",
+ last_updated: "Last updated: {{date}}",
+ save: "Save",
+ saving: "Saving...",
+ saved_success: "Policy saved successfully",
+ saved_error: "Failed to save policy",
+ },
+ evidence_tasks: {
+ evidence_tasks: "Evidence Tasks",
+ overview: "Overview",
+ },
+ risk: {
+ risks: "risks",
+ overview: "Overview",
+ create: "Create New Risk",
+ vendor: {
+ title: "Vendor Management",
+ dashboard: {
+ title: "Vendor Dashboard",
+ overview: "Vendor Overview",
+ vendor_status: "Vendor Status",
+ vendor_category: "Vendor Categories",
+ vendors_by_assignee: "Vendors by Assignee",
+ inherent_risk_description:
+ "Initial risk level before any controls are applied",
+ residual_risk_description:
+ "Remaining risk level after controls are applied",
+ },
+ register: {
+ title: "Vendor Register",
+ table: {
+ name: "Name",
+ category: "Category",
+ status: "Status",
+ owner: "Owner",
+ },
+ },
+ assessment: {
+ title: "Vendor Assessment",
+ update_success: "Vendor risk assessment updated successfully",
+ update_error: "Failed to update vendor risk assessment",
+ inherent_risk: "Inherent Risk",
+ residual_risk: "Residual Risk",
+ },
+ form: {
+ vendor_details: "Vendor Details",
+ vendor_name: "Name",
+ vendor_name_placeholder: "Enter vendor name",
+ vendor_website: "Website",
+ vendor_website_placeholder: "Enter vendor website",
+ vendor_description: "Description",
+ vendor_description_placeholder: "Enter vendor description",
+ vendor_category: "Category",
+ vendor_category_placeholder: "Select category",
+ vendor_status: "Status",
+ vendor_status_placeholder: "Select status",
+ create_vendor_success: "Vendor created successfully",
+ create_vendor_error: "Failed to create vendor",
+ update_vendor: "Update Vendor",
+ update_vendor_success: "Vendor updated successfully",
+ update_vendor_error: "Failed to update vendor",
+ add_comment: "Add Comment",
+ },
+ table: {
+ name: "Name",
+ category: "Category",
+ status: "Status",
+ owner: "Owner",
+ },
+ filters: {
+ search_placeholder: "Search vendors...",
+ status_placeholder: "Filter by status",
+ category_placeholder: "Filter by category",
+ owner_placeholder: "Filter by owner",
+ },
+ empty_states: {
+ no_vendors: {
+ title: "No vendors yet",
+ description: "Get started by creating your first vendor",
+ },
+ no_results: {
+ title: "No results found",
+ description: "No vendors match your search",
+ description_with_filters: "Try adjusting your filters",
+ },
+ },
+ actions: {
+ create: "Create Vendor",
+ },
+ status: {
+ not_assessed: "Not Assessed",
+ in_progress: "In Progress",
+ assessed: "Assessed",
+ },
+ category: {
+ cloud: "Cloud",
+ infrastructure: "Infrastructure",
+ software_as_a_service: "Software as a Service",
+ finance: "Finance",
+ marketing: "Marketing",
+ sales: "Sales",
+ hr: "HR",
+ other: "Other",
+ },
+ risk_level: {
+ low: "Low Risk",
+ medium: "Medium Risk",
+ high: "High Risk",
+ unknown: "Unknown Risk",
+ },
+ },
+ dashboard: {
+ title: "Dashboard",
+ overview: "Risk Overview",
+ risk_status: "Risk Status",
+ risks_by_department: "Risks by Department",
+ risks_by_assignee: "Risks by Assignee",
+ inherent_risk_description:
+ "Inherent risk is calculated as likelihood * impact. Calculated before any controls are applied.",
+ residual_risk_description:
+ "Residual risk is calculated as likelihood * impact. This is the risk level after controls are applied.",
+ risk_assessment_description: "Compare inherent and residual risk levels",
+ },
+ register: {
+ title: "Risk Register",
+ table: {
+ risk: "Risk",
+ },
+ empty: {
+ no_risks: {
+ title: "Create a risk to get started",
+ description:
+ "Track and score risks, create and assign mitigation tasks for your team, and manage your risk register all in one simple interface.",
+ },
+ create_risk: "Create a risk",
+ },
+ },
+ metrics: {
+ probability: "Probability",
+ impact: "Impact",
+ inherentRisk: "Inherent Risk",
+ residualRisk: "Residual Risk",
+ },
+ form: {
+ update_inherent_risk: "Save Inherent Risk",
+ update_inherent_risk_description:
+ "Update the inherent risk of the risk. This is the risk level before any controls are applied.",
+ update_inherent_risk_success: "Inherent risk updated successfully",
+ update_inherent_risk_error: "Failed to update inherent risk",
+ update_residual_risk: "Save Residual Risk",
+ update_residual_risk_description:
+ "Update the residual risk of the risk. This is the risk level after controls are applied.",
+ update_residual_risk_success: "Residual risk updated successfully",
+ update_residual_risk_error: "Failed to update residual risk",
+ update_risk: "Update Risk",
+ update_risk_description: "Update the risk title or description.",
+ update_risk_success: "Risk updated successfully",
+ update_risk_error: "Failed to update risk",
+ create_risk_success: "Risk created successfully",
+ create_risk_error: "Failed to create risk",
+ risk_details: "Risk Details",
+ risk_title: "Risk Title",
+ risk_title_description: "Enter a name for the risk",
+ risk_description: "Description",
+ risk_description_description: "Enter a description for the risk",
+ risk_category: "Category",
+ risk_category_placeholder: "Select a category",
+ risk_department: "Department",
+ risk_department_placeholder: "Select a department",
+ risk_status: "Risk Status",
+ risk_status_placeholder: "Select a risk status",
+ },
+ tasks: {
+ title: "Tasks",
+ attachments: "Attachments",
+ overview: "Task Overview",
+ form: {
+ title: "Task Details",
+ task_title: "Task Title",
+ status: "Task Status",
+ status_placeholder: "Select a task status",
+ task_title_description: "Enter a name for the task",
+ description: "Description",
+ description_description: "Enter a description for the task",
+ due_date: "Due Date",
+ due_date_description: "Select the due date for the task",
+ success: "Task created successfully",
+ error: "Failed to create task",
+ },
+ sheet: {
+ title: "Create Task",
+ update: "Update Task",
+ update_description: "Update the task title or description.",
+ },
+ empty: {
+ description_create:
+ "Create a mitigation task for this risk, add a treatment plan, and assign it to a team member.",
+ },
+ },
+ },
+ people: {
+ title: "People",
+ details: {
+ taskProgress: "Task Progress",
+ tasks: "Tasks",
+ noTasks: "No tasks assigned yet",
+ },
+ description: "Manage your team members and their roles.",
+ filters: {
+ search: "Search people...",
+ role: "Filter by role",
+ },
+ actions: {
+ invite: "Add Employee",
+ clear: "Clear filters",
+ },
+ table: {
+ name: "Name",
+ email: "Email",
+ department: "Department",
+ externalId: "External ID",
+ status: "Status",
+ },
+ empty: {
+ no_employees: {
+ title: "No employees yet",
+ description: "Get started by inviting your first team member.",
+ },
+ no_results: {
+ title: "No results found",
+ description: "No employees match your search",
+ description_with_filters: "Try adjusting your filters",
+ },
+ },
+ invite: {
+ title: "Add Employee",
+ description: "Add an employee to your organization.",
+ email: {
+ label: "Email address",
+ placeholder: "Enter email address",
+ },
+ role: {
+ label: "Role",
+ placeholder: "Select a role",
+ },
+ name: {
+ label: "Name",
+ placeholder: "Enter name",
+ },
+ department: {
+ label: "Department",
+ placeholder: "Select a department",
+ },
+ submit: "Add Employee",
+ success: "Employee added successfully",
+ error: "Failed to add employee",
+ },
+ },
+ settings: {
+ general: {
+ title: "General",
+ org_name: "Organization name",
+ org_name_description:
+ "This is your organizations visible name. You should use the legal name of your organization.",
+ org_name_tip: "Please use 32 characters at maximum.",
+ org_website: "Organization Website",
+ org_website_description:
+ "This is your organization's official website URL. Make sure to include the full URL with https://.",
+ org_website_tip: "Please enter a valid URL including https://",
+ org_website_error: "Error updating organization website",
+ org_website_updated: "Organization website updated",
+ org_delete: "Delete organization",
+ org_delete_description:
+ "Permanently remove your organization and all of its contents from the Comp AI platform. This action is not reversible - please continue with caution.",
+ org_delete_alert_title: "Are you absolutely sure?",
+ org_delete_alert_description:
+ "This action cannot be undone. This will permanently delete your organization and remove your data from our servers.",
+ org_delete_error: "Error deleting organization",
+ org_delete_success: "Organization deleted",
+ org_name_updated: "Organization name updated",
+ org_name_error: "Error updating organization name",
+ save_button: "Save",
+ delete_button: "Delete",
+ delete_confirm: "DELETE",
+ delete_confirm_tip: "Type DELETE to confirm.",
+ cancel_button: "Cancel",
+ },
+ members: {
+ title: "Members",
+ },
+ api_keys: {
+ title: "API Keys",
+ description:
+ "Manage API keys for programmatic access to your organization's data.",
+ list_title: "API Keys",
+ list_description:
+ "API keys allow secure access to your organization's data via our API.",
+ create: "New API Key",
+ create_title: "New API Key",
+ create_description:
+ "Create a new API key for programmatic access to your organization's data.",
+ created_title: "API Key Created",
+ created_description:
+ "Your API key has been created. Make sure to copy it now as you won't be able to see it again.",
+ name: "Name",
+ name_label: "Name",
+ name_placeholder: "Enter a name for this API key",
+ expiration: "Expiration",
+ expiration_placeholder: "Select expiration",
+ expires_label: "Expires",
+ expires_placeholder: "Select expiration",
+ expires_30days: "30 days",
+ expires_90days: "90 days",
+ expires_1year: "1 year",
+ expires_never: "Never",
+ thirty_days: "30 days",
+ ninety_days: "90 days",
+ one_year: "1 year",
+ your_key: "Your API Key",
+ api_key: "API Key",
+ save_warning:
+ "This key will only be shown once. Make sure to copy it now.",
+ copied: "API key copied to clipboard",
+ close_confirm:
+ "Are you sure you want to close? You won't be able to see this API key again.",
+ revoke_confirm:
+ "Are you sure you want to revoke this API key? This action cannot be undone.",
+ revoke_title: "Revoke API Key",
+ revoke: "Revoke",
+ created: "Created",
+ expires: "Expires",
+ last_used: "Last Used",
+ actions: "Actions",
+ never: "Never",
+ never_used: "Never used",
+ no_keys: "No API keys found. Create one to get started.",
+ security_note:
+ "API keys provide full access to your organization's data. Keep them secure and rotate them regularly.",
+ fetch_error: "Failed to fetch API keys",
+ create_error: "Failed to create API key",
+ revoked_success: "API key revoked successfully",
+ revoked_error: "Failed to revoke API key",
+ done: "Done",
+ },
+ billing: {
+ title: "Billing",
+ },
+ team: {
+ tabs: {
+ members: "Team Members",
+ invite: "Invite Members",
+ },
+ members: {
+ title: "Team Members",
+ empty: {
+ no_organization: {
+ title: "No Organization",
+ description: "You are not part of any organization",
+ },
+ no_members: {
+ title: "No Members",
+ description: "There are no active members in your organization",
+ },
+ },
+ role: {
+ owner: "Owner",
+ admin: "Admin",
+ member: "Member",
+ viewer: "Viewer",
+ },
+ },
+ invitations: {
+ title: "Pending Invitations",
+ description: "Users who have been invited but haven't accepted yet",
+ empty: {
+ no_organization: {
+ title: "No Organization",
+ description: "You are not part of any organization",
+ },
+ no_invitations: {
+ title: "No Pending Invitations",
+ description: "There are no pending invitations",
+ },
+ },
+ invitation_sent: "Invitation sent",
+ actions: {
+ resend: "Resend Invite",
+ sending: "Sending Invite",
+ revoke: "Revoke",
+ revoke_title: "Revoke Invitation",
+ revoke_description_prefix:
+ "Are you sure you want to revoke the invitation for",
+ revoke_description_suffix: "This action cannot be undone.",
+ },
+ toast: {
+ resend_success_prefix: "An invitation email has been sent to",
+ resend_error: "Failed to send invitation",
+ resend_unexpected:
+ "An unexpected error occurred while sending the invitation",
+ revoke_success_prefix: "Invitation to",
+ revoke_success_suffix: "has been revoked",
+ revoke_error: "Failed to revoke invitation",
+ revoke_unexpected:
+ "An unexpected error occurred while revoking the invitation",
+ },
+ },
+ invite: {
+ title: "Invite Team Member",
+ description:
+ "Send an invitation to a new team member to join your organization",
+ form: {
+ email: {
+ label: "Email",
+ placeholder: "member@example.com",
+ error: "Please enter a valid email address",
+ },
+ role: {
+ label: "Role",
+ placeholder: "Select a role",
+ error: "Please select a role",
+ },
+ department: {
+ label: "Department",
+ placeholder: "Select a department",
+ error: "Please select a department",
+ },
+ departments: {
+ none: "None",
+ it: "IT",
+ hr: "HR",
+ admin: "Admin",
+ gov: "Government",
+ itsm: "ITSM",
+ qms: "QMS",
+ },
+ },
+ button: {
+ send: "Send Invitation",
+ sending: "Sending invitation...",
+ sent: "Invitation Sent",
+ },
+ toast: {
+ error: "Failed to send invitation",
+ unexpected:
+ "An unexpected error occurred while sending the invitation",
+ },
+ },
+ member_actions: {
+ actions: "Actions",
+ change_role: "Change Role",
+ remove_member: "Remove Member",
+ remove_confirm: {
+ title: "Remove Team Member",
+ description_prefix: "Are you sure you want to remove",
+ description_suffix: "This action cannot be undone.",
+ },
+ role_dialog: {
+ title: "Change Role",
+ description_prefix: "Update the role for",
+ role_label: "Role",
+ role_placeholder: "Select a role",
+ role_descriptions: {
+ admin: "Admins can manage team members and settings.",
+ member:
+ "Members can use all features but cannot manage team members.",
+ viewer: "Viewers can only view content without making changes.",
+ },
+ cancel: "Cancel",
+ update: "Update Role",
+ },
+ toast: {
+ remove_success: "has been removed from the organization",
+ remove_error: "Failed to remove member",
+ remove_unexpected:
+ "An unexpected error occurred while removing the member",
+ update_role_success: "has had their role updated to",
+ update_role_error: "Failed to update member role",
+ update_role_unexpected:
+ "An unexpected error occurred while updating the member's role",
+ },
+ },
+ },
+ },
+ tests: {
+ dashboard: {
+ overview: "Overview",
+ all: "All Tests",
+ tests_by_assignee: "Tests by Assignee",
+ passed: "Passed",
+ failed: "Failed",
+ severity_distribution: "Test Severity Distribution",
+ },
+ severity: {
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ critical: "Critical",
+ },
+ name: "Cloud Tests",
+ title: "Cloud Tests",
+ actions: {
+ create: "Add Cloud Test",
+ clear: "Clear filters",
+ refresh: "Refresh",
+ refresh_success: "Tests refreshed successfully",
+ refresh_error: "Failed to refresh tests",
+ },
+ empty: {
+ no_tests: {
+ title: "No cloud tests yet",
+ description: "Get started by creating your first cloud test.",
+ },
+ no_results: {
+ title: "No results found",
+ description: "No tests match your search",
+ description_with_filters: "Try adjusting your filters",
+ },
+ },
+ filters: {
+ search: "Search tests...",
+ role: "Filter by vendor",
+ },
+ register: {
+ title: "Add Cloud Test",
+ description: "Configure a new cloud compliance test.",
+ submit: "Create Test",
+ success: "Test created successfully",
+ invalid_json: "Invalid JSON configuration provided",
- title_field: {
- label: "Test Title",
- placeholder: "Enter test title",
- },
- description_field: {
- label: "Description",
- placeholder: "Enter test description",
- },
- provider: {
- label: "Cloud Provider",
- placeholder: "Select cloud provider",
- },
- config: {
- label: "Test Configuration",
- placeholder: "Enter JSON configuration for the test",
- },
- auth_config: {
- label: "Authentication Configuration",
- placeholder: "Enter JSON authentication configuration",
- },
- },
- table: {
- title: "Title",
- provider: "Provider",
- status: "Status",
- severity: "Severity",
- result: "Result",
- createdAt: "Created At",
- assignedUser: "Assigned User",
- assignedUserEmpty: "Not Assigned",
- no_results: "No results found",
- },
- },
- user_menu: {
- theme: "Theme",
- language: "Language",
- sign_out: "Sign out",
- account: "Account",
- support: "Support",
- settings: "Settings",
- teams: "Teams",
- },
- frameworks: {
- title: "Frameworks",
- overview: {
- error: "Failed to load frameworks",
- loading: "Loading frameworks...",
- empty: {
- title: "No frameworks selected",
- description:
- "Select frameworks to get started with your compliance journey",
- },
- progress: {
- title: "Framework Progress",
- empty: {
- title: "No frameworks yet",
- description:
- "Get started by adding a compliance framework to track your progress",
- action: "Add Framework",
- },
- },
- grid: {
- welcome: {
- title: "Welcome to Comp AI",
- description:
- "Get started by selecting the compliance frameworks you would like to implement. We'll help you manage and track your compliance journey across multiple standards.",
- action: "Get Started",
- },
- title: "Select Frameworks",
- version: "Version",
- actions: {
- clear: "Clear",
- confirm: "Confirm Selection",
- },
- },
- },
- controls: {
- title: "Controls",
- description: "Review and manage compliance controls",
- table: {
- status: "Status",
- control: "Control",
- artifacts: "Artifacts",
- actions: "Actions",
- },
- statuses: {
- completed: "Completed",
- in_progress: "In Progress",
- not_started: "Not Started",
- },
- },
- },
- errors: {
- unexpected: "Something went wrong, please try again",
- },
- editor: {
- ai: {
- thinking: "AI is thinking",
- thinking_spinner: "AI is thinking",
- edit_or_generate: "Edit or generate...",
- tell_ai_what_to_do_next: "Tell AI what to do next",
- request_limit_reached: "You have reached your request limit for the day.",
- },
- ai_selector: {
- improve: "Improve writing",
- fix: "Fix grammar",
- shorter: "Make shorter",
- longer: "Make longer",
- continue: "Continue writing",
- replace: "Replace selection",
- insert: "Insert below",
- discard: "Discard",
- },
- },
- evidence: {
- title: "Evidence",
- list: "All Evidence",
- overview: "Evidence Overview",
- edit: "Uploaded Evidence",
- dashboard: {
- layout: "Dashboard",
- layout_back_button: "Back",
- title: "Evidence Dashboard",
- by_department: "By Department",
- by_assignee: "By Assignee",
- by_framework: "By Framework",
- },
- items: "items",
- status: {
- up_to_date: "Up to Date",
- needs_review: "Needs Review",
- draft: "Draft",
- empty: "Empty",
- },
- departments: {
- none: "Uncategorized",
- admin: "Administration",
- gov: "Governance",
- hr: "Human Resources",
- it: "Information Technology",
- itsm: "IT Service Management",
- qms: "Quality Management",
- },
- details: {
- review_section: "Review Information",
- content: "Evidence Content",
- },
- },
- vendors: {
- title: "Vendors",
- register: {
- title: "Vendor Register",
- },
- dashboard: {
- title: "Vendor Overview",
- },
- },
- dashboard: {
- risk_status: "Risk Status",
- risks_by_department: "Risks by Department",
- vendor_status: "Vendor Status",
- vendors_by_category: "Vendors by Category",
- },
+ title_field: {
+ label: "Test Title",
+ placeholder: "Enter test title",
+ },
+ description_field: {
+ label: "Description",
+ placeholder: "Enter test description",
+ },
+ provider: {
+ label: "Cloud Provider",
+ placeholder: "Select cloud provider",
+ },
+ config: {
+ label: "Test Configuration",
+ placeholder: "Enter JSON configuration for the test",
+ },
+ auth_config: {
+ label: "Authentication Configuration",
+ placeholder: "Enter JSON authentication configuration",
+ },
+ },
+ table: {
+ title: "Title",
+ provider: "Provider",
+ status: "Status",
+ severity: "Severity",
+ result: "Result",
+ createdAt: "Created At",
+ assignedUser: "Assigned User",
+ assignedUserEmpty: "Not Assigned",
+ no_results: "No results found",
+ },
+ },
+ user_menu: {
+ theme: "Theme",
+ language: "Language",
+ sign_out: "Sign out",
+ account: "Account",
+ support: "Support",
+ settings: "Settings",
+ teams: "Teams",
+ },
+ frameworks: {
+ title: "Frameworks",
+ overview: {
+ error: "Failed to load frameworks",
+ loading: "Loading frameworks...",
+ empty: {
+ title: "No frameworks selected",
+ description:
+ "Select frameworks to get started with your compliance journey",
+ },
+ progress: {
+ title: "Framework Progress",
+ empty: {
+ title: "No frameworks yet",
+ description:
+ "Get started by adding a compliance framework to track your progress",
+ action: "Add Framework",
+ },
+ },
+ grid: {
+ welcome: {
+ title: "Welcome to Comp AI",
+ description:
+ "Get started by selecting the compliance frameworks you would like to implement. We'll help you manage and track your compliance journey across multiple standards.",
+ action: "Get Started",
+ },
+ title: "Select Frameworks",
+ version: "Version",
+ actions: {
+ clear: "Clear",
+ confirm: "Confirm Selection",
+ },
+ },
+ },
+ controls: {
+ title: "Controls",
+ description: "Review and manage compliance controls",
+ table: {
+ status: "Status",
+ control: "Control",
+ artifacts: "Artifacts",
+ actions: "Actions",
+ },
+ statuses: {
+ completed: "Completed",
+ in_progress: "In Progress",
+ not_started: "Not Started",
+ },
+ },
+ },
+ errors: {
+ unexpected: "Something went wrong, please try again",
+ },
+ editor: {
+ ai: {
+ thinking: "AI is thinking",
+ thinking_spinner: "AI is thinking",
+ edit_or_generate: "Edit or generate...",
+ tell_ai_what_to_do_next: "Tell AI what to do next",
+ request_limit_reached: "You have reached your request limit for the day.",
+ },
+ ai_selector: {
+ improve: "Improve writing",
+ fix: "Fix grammar",
+ shorter: "Make shorter",
+ longer: "Make longer",
+ continue: "Continue writing",
+ replace: "Replace selection",
+ insert: "Insert below",
+ discard: "Discard",
+ },
+ },
+ evidence: {
+ title: "Evidence",
+ list: "All Evidence",
+ overview: "Evidence Overview",
+ edit: "Uploaded Evidence",
+ dashboard: {
+ layout: "Dashboard",
+ layout_back_button: "Back",
+ title: "Evidence Dashboard",
+ by_department: "By Department",
+ by_assignee: "By Assignee",
+ by_framework: "By Framework",
+ },
+ items: "items",
+ status: {
+ up_to_date: "Up to Date",
+ needs_review: "Needs Review",
+ draft: "Draft",
+ empty: "Empty",
+ },
+ departments: {
+ none: "Uncategorized",
+ admin: "Administration",
+ gov: "Governance",
+ hr: "Human Resources",
+ it: "Information Technology",
+ itsm: "IT Service Management",
+ qms: "Quality Management",
+ },
+ details: {
+ review_section: "Review Information",
+ content: "Evidence Content",
+ },
+ },
+ vendors: {
+ title: "Vendors",
+ register: {
+ title: "Vendor Register",
+ },
+ dashboard: {
+ title: "Vendor Overview",
+ },
+ },
+ dashboard: {
+ risk_status: "Risk Status",
+ risks_by_department: "Risks by Department",
+ vendor_status: "Vendor Status",
+ vendors_by_category: "Vendors by Category",
+ },
} as const;
diff --git a/yarn.lock b/yarn.lock
index 45a7b48f14..c982a4d35f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1041,18 +1041,19 @@
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:
"@bubba/tsconfig" "workspace:*"
+ devDependencies:
typescript "^5.8.2"
dependencies:
"@auth/prisma-adapter" "^2.8.0"
@@ -1062,7 +1063,7 @@
"@prisma/extension-accelerate" "^1.2.2"
prisma "^6.5.0"
-"@bubba/email@packages/email":
+"@bubba/email@^workspace:packages/email":
version "workspace:packages/email"
resolved "workspace:packages/email"
devDependencies:
@@ -1082,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:
@@ -1096,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:
@@ -1112,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:
@@ -1180,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:
@@ -6427,7 +6428,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:
@@ -11053,7 +11054,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:
@@ -13711,7 +13712,7 @@ 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: