diff --git a/apps/app/src/app/(internal)/internal/admin/actions.ts b/apps/app/src/app/(internal)/internal/admin/actions.ts deleted file mode 100644 index 6ab8723f2f..0000000000 --- a/apps/app/src/app/(internal)/internal/admin/actions.ts +++ /dev/null @@ -1,284 +0,0 @@ -"use server"; - -import { headers } from "next/headers"; -import { z } from "zod"; -import { auth } from "@/utils/auth"; -import { db } from "@comp/db"; -import type { Member, Organization, User } from "@comp/db/types"; -import { Role } from "@comp/db/types"; -import type { ActionResponse } from "@/types/actions"; - -// Define the detailed Organization type expected by the frontend -interface OrganizationWithMembersAndUsers extends Organization { - members: (Member & { user: User })[]; -} - -// Define the Member type expected by the frontend -interface MemberWithUser extends Member { - user: User; -} - -// --- Fetch All Organizations --- Replaces fetchOrganizationsAction -export async function fetchOrganizations(): Promise< - ActionResponse -> { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.email?.endsWith("@trycomp.ai")) { - return { success: false, error: "Unauthorized: Admin access required" }; - } - - try { - const organizations = await db.organization.findMany({ - include: { - members: { - include: { - user: true, - }, - }, - }, - orderBy: { - name: "asc", - }, - }); - return { success: true, data: organizations }; - } catch (error) { - console.error("Error fetching organizations:", error); - return { success: false, error: "Failed to fetch organizations" }; - } -} - -// --- Fetch Admin Users (@trycomp.ai & @securis360.com) --- -export async function fetchAdminUsers(): Promise> { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.email?.endsWith("@trycomp.ai")) { - return { success: false, error: "Unauthorized: Admin access required" }; - } - - try { - const adminUsers = await db.user.findMany({ - where: { - OR: [ - { - email: { - endsWith: "@trycomp.ai", - }, - }, - { - email: { - endsWith: "@securis360.com", - }, - }, - ], - }, - orderBy: { - email: "asc", - }, - }); - return { success: true, data: adminUsers }; - } catch (error) { - console.error("Error fetching admin users:", error); - return { success: false, error: "Failed to fetch admin users" }; - } -} - -// --- Add Member to Organization --- Renamed from addSelfToOrg -const addMemberSchema = z.object({ - organizationId: z.string(), - targetUserId: z.string(), // ID of the user to add -}); - -export async function addMemberToOrg( - input: z.infer, -): Promise> { - // Authorization check: Ensure the CALLER is an admin - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.email?.endsWith("@trycomp.ai")) { - return { - success: false, - error: "Unauthorized: Caller is not an admin", - }; - } - - // Validate input - const parseResult = addMemberSchema.safeParse(input); - if (!parseResult.success) { - return { success: false, error: "Invalid input" }; - } - const { organizationId, targetUserId } = parseResult.data; - - try { - // Check if member already exists - const existingMember = await db.member.findFirst({ - where: { - organizationId: organizationId, - userId: targetUserId, // Check for the target user - }, - include: { user: true }, - }); - - if (existingMember) { - return { success: true, data: existingMember }; // Already a member - } - - // Use Kinde server-side API to add the TARGET member - // Note: Ensure Kinde API allows adding arbitrary users by an authorized admin - // This might require specific Kinde setup or permissions. - await auth.api.addMember({ - body: { - userId: targetUserId, // Use the target user ID - organizationId: organizationId, - role: Role.admin, // Defaulting to admin for now - }, - }); - - // Refetch the newly created member with user details - const createdMember = await db.member.findFirst({ - where: { - organizationId: organizationId, - userId: targetUserId, - }, - include: { user: true }, - }); - - if (!createdMember) { - console.error( - "Failed to retrieve member immediately after adding via Kinde API.", - ); - return { - success: false, - error: "Failed to confirm membership creation.", - }; - } - - return { success: true, data: createdMember }; - } catch (error) { - console.error("Error adding member to organization:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to add member"; - return { success: false, error: errorMessage }; - } -} - -// --- Remove Member from Organization --- -const removeMemberSchema = z.object({ - organizationId: z.string(), - targetUserId: z.string(), // ID of the user to remove -}); - -export async function removeMember( - input: z.infer, -): Promise> { - // Authorization check: Ensure the CALLER is an admin - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.email?.endsWith("@trycomp.ai")) { - return { - success: false, - error: "Unauthorized: Caller is not an admin", - }; - } - - // Validate input - const parseResult = removeMemberSchema.safeParse(input); - if (!parseResult.success) { - return { success: false, error: "Invalid input" }; - } - const { organizationId, targetUserId } = parseResult.data; - - // Prevent admin from removing themselves via this action (they should use leaveOrganization) - if (session.user?.id === targetUserId) { - return { - success: false, - error: "Admin cannot remove self using this action. Use 'Leave Organization'.", - }; - } - - try { - // Check if the target user is actually a member - const targetMember = await db.member.findFirst({ - where: { - organizationId: organizationId, - userId: targetUserId, - }, - }); - - if (!targetMember) { - return { - success: false, - error: "Target user is not a member of this organization.", - }; - } - - // Prevent removing the organization owner - if (targetMember.role === Role.owner) { - return { - success: false, - error: "Cannot remove the organization owner.", - }; - } - - // Use Kinde server-side API to remove the TARGET member - // Check Kinde documentation for the correct server-side method. - // Assuming a hypothetical `auth.api.removeMember` exists. - // If not, direct DB deletion is needed, but ensure cascading deletes or other relations are handled. - - // Placeholder for Kinde remove member API call or DB operation - // await auth.api.removeMember({ organizationId, userId: targetUserId }); - // OR direct DB deletion: - await db.member.deleteMany({ - where: { - organizationId: organizationId, - userId: targetUserId, - }, - }); - // Consider if associated sessions for the removed user should be invalidated: - // await db.session.deleteMany({ where: { userId: targetUserId, organizationId: organizationId } }); - - return { success: true, data: { removed: true } }; - } catch (error) { - console.error("Error removing member from organization:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to remove member"; - return { success: false, error: errorMessage }; - } -} - -// --- Fetch Organization Members --- Replaces fetchOrgMembersAction -const fetchMembersSchema = z.object({ - organizationId: z.string(), -}); - -export async function fetchOrgMembers( - input: z.infer, -): Promise> { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.email?.endsWith("@trycomp.ai")) { - return { success: false, error: "Unauthorized: Admin access required" }; - } - - // Validate input - const parseResult = fetchMembersSchema.safeParse(input); - if (!parseResult.success) { - return { success: false, error: "Invalid input" }; - } - const { organizationId } = parseResult.data; - - try { - const members = await db.member.findMany({ - where: { - organizationId: organizationId, - }, - include: { - user: true, - }, - orderBy: { - user: { - name: "asc", - }, - }, - }); - return { success: true, data: members }; - } catch (error) { - console.error("Error fetching organization members:", error); - return { success: false, error: "Failed to fetch members" }; - } -} diff --git a/apps/app/src/app/(internal)/internal/admin/members-modal.tsx b/apps/app/src/app/(internal)/internal/admin/members-modal.tsx deleted file mode 100644 index 4db9b53d0c..0000000000 --- a/apps/app/src/app/(internal)/internal/admin/members-modal.tsx +++ /dev/null @@ -1,240 +0,0 @@ -"use client"; - -import { useState, useEffect, useMemo } from "react"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@comp/ui/dialog"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@comp/ui/table"; -import { Badge } from "@comp/ui/badge"; -import { Button } from "@comp/ui/button"; -import { Input } from "@comp/ui/input"; -import { Skeleton } from "@comp/ui/skeleton"; -import { Search } from "lucide-react"; -import type { Member, Organization, User } from "@comp/db/types"; -import { fetchOrgMembers } from "./actions"; // Import the standard async action - -// Type definition for the organization prop, including nested members with users initially -interface OrganizationWithMembers extends Organization { - members: (Member & { user: User })[]; -} - -// Type definition for Member with User included -interface MemberWithUser extends Member { - user: User; -} - -interface MembersModalProps { - organization: OrganizationWithMembers | null; // Allow null initially - isOpen: boolean; - onClose: () => void; -} - -export function MembersModal({ - organization, - isOpen, - onClose, -}: MembersModalProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [members, setMembers] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // Fetch members when the modal opens or the organization changes - useEffect(() => { - if (isOpen && organization) { - const loadMembers = async () => { - setIsLoading(true); - setError(null); - try { - const result = await fetchOrgMembers({ - organizationId: organization.id, - }); - if (result.success && result.data) { - setMembers(result.data); - } else { - const errorMsg = result.error || "Failed to load members."; - setError(errorMsg); - toast.error(errorMsg); - } - } catch (err) { - console.error("Error fetching members:", err); - const errorMsg = - "An unexpected error occurred while fetching members."; - setError(errorMsg); - toast.error(errorMsg); - } finally { - setIsLoading(false); - } - }; - loadMembers(); - } - }, [isOpen, organization]); - - // Filter members based on search query - const filteredMembers = useMemo(() => { - if (!members) return []; - if (searchQuery.trim() === "") { - return members; - } - const query = searchQuery.toLowerCase(); - return members.filter( - (member) => - member.user.name?.toLowerCase().includes(query) || - member.user.email.toLowerCase().includes(query) || - member.id.toLowerCase().includes(query) || - member.role.toLowerCase().includes(query), - ); - }, [searchQuery, members]); - - // Prevent rendering the dialog server-side or if no organization is selected - if (!organization) { - return null; - } - - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - }; - - const getRoleBadgeClass = (role: string) => { - switch (role) { - case "owner": - return "bg-destructive text-background"; - case "admin": - return "bg-default text-background"; - default: - return "bg-secondary text-background"; - } - }; - - return ( - - - - - {organization?.name} - Members - - - View and search members of this organization. - - - - {/* Search Input */} -
- - -
- - {/* Members Table */} -
- - - - Name - Email - Role - Member ID - User ID - - - - {isLoading ? ( - Array.from({ length: 3 }).map((_, index) => ( - - - - - - - - - - - - - - - - - - )) - ) : error ? ( - - - Error loading members: {error} - - - ) : filteredMembers.length === 0 ? ( - - - {searchQuery - ? "No members match your search." - : "No members in this organization."} - - - ) : ( - filteredMembers.map((member) => ( - - - {member.user.name || "N/A"} - - {member.user.email} - - - {member.role} - - - - {member.id} - - - {member.userId} - - - )) - )} - -
-
- - - - -
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/admin/organization-dashboard.tsx b/apps/app/src/app/(internal)/internal/admin/organization-dashboard.tsx deleted file mode 100644 index 7fce1203da..0000000000 --- a/apps/app/src/app/(internal)/internal/admin/organization-dashboard.tsx +++ /dev/null @@ -1,467 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { toast } from "sonner"; -import { authClient } from "@/utils/auth-client"; -import type { Member, Organization, User } from "@comp/db/types"; -import { - fetchOrganizations, - addMemberToOrg, - fetchAdminUsers, - removeMember, -} from "./actions"; -import { OrganizationList } from "./organization-list"; -import { MembersModal } from "./members-modal"; -import { Input } from "@comp/ui/input"; -import { Search } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@comp/ui/select"; -import { Skeleton } from "@comp/ui/skeleton"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@comp/ui/card"; - -// Define the specific types returned by our actions -interface OrganizationWithMembers extends Organization { - members: (Member & { user: User })[]; -} - -// Update props to accept individual ID and email -interface OrganizationDashboardProps { - loggedInUserId: string; - loggedInUserEmail: string; -} - -export function OrganizationDashboard({ - loggedInUserId, - loggedInUserEmail, -}: OrganizationDashboardProps) { - // --- State Management --- - - // Organization Data & Filtering - const [organizations, setOrganizations] = useState( - [], - ); - const [searchQuery, setSearchQuery] = useState(""); - const [filteredOrganizations, setFilteredOrganizations] = useState< - OrganizationWithMembers[] - >([]); - const [isFetchingOrgs, setIsFetchingOrgs] = useState(true); - const [fetchOrgsError, setFetchOrgsError] = useState(null); - - // Admin User Context - const [adminUsers, setAdminUsers] = useState([]); - const [actingAsUser, setActingAsUser] = useState(null); // Current user context - const [isFetchingAdmins, setIsFetchingAdmins] = useState(true); - - // Member Actions (Add/Remove Self) - const [isAddingMember, setIsAddingMember] = useState(false); - const [addMemberError, setAddMemberError] = useState(null); - const [isRemovingMember, setIsRemovingMember] = useState(false); - const [removeMemberError, setRemoveMemberError] = useState( - null, - ); - - // Modal State - const [selectedOrg, setSelectedOrg] = - useState(null); // Org selected for modal view - const [isModalOpen, setIsModalOpen] = useState(false); - - // --- Data Fetching Functions --- - - /** - * Fetches all organizations and their members. - * Applies the current search filter after fetching. - */ - const loadOrganizations = useCallback(async () => { - setIsFetchingOrgs(true); - setFetchOrgsError(null); - try { - const result = await fetchOrganizations(); - if (result.success && result.data) { - setOrganizations(result.data); - // Apply current search query immediately after fetching - if (searchQuery.trim() === "") { - setFilteredOrganizations(result.data); - } else { - // Re-apply filter logic (extracted for reuse) - filterOrgs(searchQuery, result.data); - } - } else { - const errorMsg = result.error || "Failed to fetch organizations."; - setFetchOrgsError(errorMsg); - toast.error(errorMsg); - setOrganizations([]); // Clear lists on error - setFilteredOrganizations([]); - } - } catch (error) { - console.error("Fetch orgs error:", error); - const errorMsg = - "An unexpected error occurred while fetching organizations."; - setFetchOrgsError(errorMsg); - toast.error(errorMsg); - setOrganizations([]); - setFilteredOrganizations([]); - } finally { - setIsFetchingOrgs(false); - } - }, [searchQuery]); // Add searchQuery dependency - - /** - * Fetches the list of admin users for the 'Acting As' dropdown. - * Sets the initial 'Acting As' user based on loggedInUserId. - * Handles cases where the logged-in user might not be in the fetched list. - */ - const loadAdminUsers = useCallback(async () => { - setIsFetchingAdmins(true); - try { - const result = await fetchAdminUsers(); - if (result.success && result.data) { - const fetchedAdmins = result.data; - setAdminUsers(fetchedAdmins); - // Find the full user object corresponding to the logged-in user - const currentLoggedInUser = fetchedAdmins.find( - (u) => u.id === loggedInUserId, - ); - if (currentLoggedInUser) { - setActingAsUser(currentLoggedInUser); // Set the full user object - } else { - // Logged-in user not found in admin list (might indicate an issue) - console.warn( - "Logged-in admin user info not found in fetched admin list.", - ); - // Add a minimal representation for the dropdown if not already present - if (!fetchedAdmins.some((u) => u.id === loggedInUserId)) { - const minimalLoggedInUserRep: User = { - id: loggedInUserId, - email: loggedInUserEmail, - name: "You (Not Found)", // Indicate issue - // Add other required fields with defaults - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - image: null, - lastLogin: null, - }; - setAdminUsers((prev) => [minimalLoggedInUserRep, ...prev]); - } - // Keep actingAsUser null until a valid selection is made - setActingAsUser(null); - } - } else { - toast.error(result.error || "Failed to fetch admin users."); - // Add minimal representation for dropdown fallback - const minimalLoggedInUserRep: User = { - id: loggedInUserId, - email: loggedInUserEmail, - name: "You (Error)", - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - image: null, - lastLogin: null, - }; - setAdminUsers([minimalLoggedInUserRep]); - setActingAsUser(null); // Set to null on error - } - } catch (error) { - console.error("Fetch admin users error:", error); - toast.error("An unexpected error occurred while fetching admin users."); - // Add minimal representation for dropdown fallback - const minimalLoggedInUserRep: User = { - id: loggedInUserId, - email: loggedInUserEmail, - name: "You (Error)", - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - image: null, - lastLogin: null, - }; - setAdminUsers([minimalLoggedInUserRep]); - setActingAsUser(null); // Set to null on error - } finally { - setIsFetchingAdmins(false); - } - }, [loggedInUserId, loggedInUserEmail]); // Use ID and Email props as deps - - // --- Filtering Logic --- - - /** - * Filters the base list of organizations based on the search query. - * Updates the `filteredOrganizations` state. - */ - const filterOrgs = useCallback( - (query: string, baseOrgs: OrganizationWithMembers[]) => { - const lowerQuery = query.toLowerCase(); - const filtered = baseOrgs.filter((org) => { - // Check org name, slug, id - if ( - org.name.toLowerCase().includes(lowerQuery) || - org.slug.toLowerCase().includes(lowerQuery) || - org.id.toLowerCase().includes(lowerQuery) - ) { - return true; - } - // Check member name, email, id - return org.members.some( - (member) => - member.user.name?.toLowerCase().includes(lowerQuery) || - member.user.email.toLowerCase().includes(lowerQuery) || - member.id.toLowerCase().includes(lowerQuery), - ); - }); - setFilteredOrganizations(filtered); - }, - [], // No dependencies needed as it only uses arguments - ); - - // Update filtered orgs whenever the base organizations or search query changes - useEffect(() => { - filterOrgs(searchQuery, organizations); - }, [searchQuery, organizations, filterOrgs]); - - // --- Event Handlers --- - - /** - * Handles changes in the search input field. - */ - const handleSearchChange = (event: React.ChangeEvent) => { - setSearchQuery(event.target.value); - }; - - /** - * Handles selection changes in the 'Acting As' dropdown. - */ - const handleActingUserChange = (userId: string) => { - const selectedUser = adminUsers.find((u) => u.id === userId); - setActingAsUser(selectedUser || null); // Set to null if user not found (shouldn't happen) - }; - - /** - * Handles adding the 'Acting As' user to the specified organization. - */ - const handleAddMember = useCallback( - async (organizationId: string) => { - if (!actingAsUser) { - toast.error("No acting user selected."); - return; - } - - setIsAddingMember(true); - setAddMemberError(null); - try { - const result = await addMemberToOrg({ - organizationId, - targetUserId: actingAsUser.id, - }); - - if (result.success) { - toast.success( - `Successfully added ${actingAsUser.email} to organization.`, - ); - // Refresh the organizations list to show the new member status - await loadOrganizations(); - } else { - const errorMsg = result.error || "Failed to add member."; - setAddMemberError(errorMsg); - toast.error(errorMsg); - } - } catch (error) { - console.error("Add member error:", error); - const errorMsg = - "An unexpected error occurred while adding the member."; - setAddMemberError(errorMsg); - toast.error(errorMsg); - } finally { - setIsAddingMember(false); - } - }, - [actingAsUser, loadOrganizations], - ); - - /** - * Handles removing the 'Acting As' user from the specified organization. - */ - const handleRemoveMember = useCallback( - async (organizationId: string) => { - if (!actingAsUser) { - toast.error("No acting user selected."); - return; - } - - // Check if trying to remove self (logged-in user) - if (actingAsUser.id === loggedInUserId) { - toast.error( - "Cannot remove yourself using 'Act As'. Use organization settings.", - ); - return; - } - - setIsRemovingMember(true); - setRemoveMemberError(null); - try { - const result = await removeMember({ - targetUserId: actingAsUser.id, - organizationId: organizationId, - }); - - if (result.success) { - toast.success( - `Successfully removed ${actingAsUser.email} from organization.`, - ); - // Refresh the organizations list to show the updated member status - await loadOrganizations(); - } else { - const errorMsg = result.error || "Failed to remove member."; - setRemoveMemberError(errorMsg); - toast.error(errorMsg); - } - } catch (error) { - console.error("Remove member error:", error); - const errorMsg = - "An unexpected error occurred while removing the member."; - setRemoveMemberError(errorMsg); - toast.error(errorMsg); - } finally { - setIsRemovingMember(false); - } - }, - [actingAsUser, organizations, loadOrganizations], - ); - - /** - * Handles opening the members modal for the selected organization. - */ - const handleViewMembers = (organization: OrganizationWithMembers) => { - setSelectedOrg(organization); - setIsModalOpen(true); - }; - - // --- Initial Data Load Effect --- - useEffect(() => { - loadOrganizations(); - loadAdminUsers(); - // Run only on initial mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // --- Render --- - return ( -
- {/* Controls Card */} - - - Controls - - Filter organizations and select user context. - - - -
- {/* Search Input with rounded-sm corners */} -
- - -
- - {/* Acting As Dropdown with rounded-sm corners */} -
- - Acting As: - -
- {isFetchingAdmins ? ( - - ) : ( - - )} -
-
-
-
-
- - {/* Loading State */} - {isFetchingOrgs && ( - - - Organizations - - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
-
- )} - - {/* Error State */} - {fetchOrgsError && ( - - - Error - - -

{fetchOrgsError}

-
-
- )} - - {/* Organizations List */} - {!isFetchingOrgs && !fetchOrgsError && ( - - )} - - {selectedOrg && ( - setIsModalOpen(false)} - /> - )} -
- ); -} diff --git a/apps/app/src/app/(internal)/internal/admin/organization-list.tsx b/apps/app/src/app/(internal)/internal/admin/organization-list.tsx deleted file mode 100644 index 2d9ba05925..0000000000 --- a/apps/app/src/app/(internal)/internal/admin/organization-list.tsx +++ /dev/null @@ -1,223 +0,0 @@ -"use client"; - -import type { Member, Organization, User } from "@comp/db/types"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@comp/ui/table"; -import { Button } from "@comp/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@comp/ui/card"; -import { Badge } from "@comp/ui/badge"; -import { Skeleton } from "@comp/ui/skeleton"; -import { UserPlus, UserMinus, Users } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@comp/ui/tooltip"; -import type * as React from "react"; - -// Type definition for the organization prop, including nested members with users -interface OrganizationWithMembers extends Organization { - members: (Member & { user: User })[]; -} - -interface OrganizationListProps { - organizations: OrganizationWithMembers[]; - actingAsUserId: string | null; - isLoading: boolean; - onAddSelf: (organizationId: string) => Promise; - onRemoveSelf: (organizationId: string) => Promise; - onViewMembers: (organization: OrganizationWithMembers) => void; -} - -export function OrganizationList({ - organizations, - actingAsUserId, - isLoading, - onAddSelf, - onRemoveSelf, - onViewMembers, -}: OrganizationListProps) { - if (isLoading) { - return ( - - - Organizations - - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
-
- ); - } - - if (organizations.length === 0) { - return ( - - - Organizations - - -
-

No organizations found.

-
-
-
- ); - } - - return ( - - - - Organizations ({organizations.length}) - - -
-
- - - - Name - Slug - ID - Members - Actions - - - - {organizations.map((organization) => { - // Find the member status for the currently acting user - const actingUserMember = actingAsUserId - ? organization.members.find( - (m) => m.userId === actingAsUserId, - ) - : null; - - const isMember = !!actingUserMember; - // Check if the acting user is the owner of this specific org - const isOwner = actingUserMember?.role === "owner"; - - return ( - - - {organization.name} - {isMember && ( - - Member - - )} - - {organization.slug} - - {organization.id} - - - - - - - -

View members

-
-
-
- - {isMember ? ( - - - {/* Span needed for disabled button tooltip trigger - removed tabIndex per lint */} - - - - - {isOwner && ( - -

- Organization owners cannot be removed via - this action. -

-
- )} -
- ) : ( - - )} -
-
- ); - })} -
-
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/admin/page.tsx b/apps/app/src/app/(internal)/internal/admin/page.tsx deleted file mode 100644 index 78da61cead..0000000000 --- a/apps/app/src/app/(internal)/internal/admin/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { auth } from "@/utils/auth"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -import { OrganizationDashboard } from "./organization-dashboard"; -import PageWithBreadcrumb from "@/components/pages/PageWithBreadcrumb"; - -export default async function Page() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - redirect("/"); - } - - if (!session.user.email?.endsWith("@trycomp.ai")) { - redirect("/"); - } - - // We have a validated admin user session here - const { email, id } = session.user; - - if (!email || !id) { - // Handle case where email or id might be unexpectedly missing - console.error("Admin user session missing email or ID."); - redirect("/"); - } - - return ( -
- - {/* Pass required fields instead of the full object */} - - -
- ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/api/organizations/route.ts b/apps/app/src/app/(internal)/internal/dashboard/api/organizations/route.ts deleted file mode 100644 index 9e029c919f..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/api/organizations/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { db } from "@comp/db"; -import dayjs from "dayjs"; -// No need for Prisma import if not using enums/types directly -import { NextRequest, NextResponse } from "next/server"; - -// Type for the raw data fetched from the database -interface RawOrganizationData { - createdAt: Date; -} - -// Helper function to aggregate counts by date, ensuring all dates in the range are present -function aggregateCountsByDate( - data: RawOrganizationData[], - startDate: Date, - endDate: Date = new Date(), -): Array<{ date: string; count: number }> { - const counts: Record = {}; - const start = dayjs(startDate).startOf("day"); - const end = dayjs(endDate).startOf("day"); - let current = start; - - // Initialize counts for all dates in the range with 0 - while (current.isBefore(end) || current.isSame(end)) { - counts[current.format("YYYY-MM-DD")] = 0; - current = current.add(1, "day"); - } - - // Increment counts based on the fetched data - for (const item of data) { - const dateStr = dayjs(item.createdAt).format("YYYY-MM-DD"); - if (counts[dateStr] !== undefined) { - counts[dateStr]++; - } - } - - return Object.entries(counts) - .map(([date, count]) => ({ date, count })) - .sort((a, b) => a.date.localeCompare(b.date)); -} - -const ANALYTICS_SECRET = process.env.ANALYTICS_SECRET; -const TODAY = dayjs().endOf("day").toDate(); -const THIRTY_DAYS_AGO = dayjs().subtract(30, "days").startOf("day").toDate(); -const SIXTY_DAYS_AGO = dayjs().subtract(60, "days").startOf("day").toDate(); // Needed for comparison period - -// Helper function to calculate percentage change safely -function calculatePercentageChange(current: number, previous: number): number { - if (previous === 0) { - // Handle division by zero: If previous was 0, change is infinite or undefined. - // Return 100% if current is positive, 0% if current is also 0. - return current > 0 ? 100 : 0; - } - return ((current - previous) / previous) * 100; -} - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const secret = searchParams.get("secret"); - - if (!ANALYTICS_SECRET || secret !== ANALYTICS_SECRET) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const [ - // Count organizations created in the last 30 days - countLast30Days, - // Count organizations created between 60 and 30 days ago (for comparison) - count30To60DaysAgo, - // Fetch createdAt timestamps for the last 30 days (for daily breakdown) - organizationsCreatedLast30DaysRaw, - // Count all organizations regardless of creation date - allTimeTotal, - ] = await Promise.all([ - // Query 1: Count organizations created in the last 30 days - db.organization.count({ - where: { - createdAt: { gte: THIRTY_DAYS_AGO }, - }, - }), - // Query 2: Count organizations created between 60 and 30 days ago - db.organization.count({ - where: { - createdAt: { gte: SIXTY_DAYS_AGO, lt: THIRTY_DAYS_AGO }, - }, - }), - // Query 3: Fetch organizations created in the last 30 days - db.organization.findMany({ - where: { - createdAt: { gte: THIRTY_DAYS_AGO }, - }, - select: { createdAt: true }, - orderBy: { createdAt: "asc" }, - }), - // Query 4: Count all organizations (all-time total) - db.organization.count(), - ]); - - // Calculate percentage change - const changeLast30Days = calculatePercentageChange( - countLast30Days, - count30To60DaysAgo, - ); - - // Aggregate counts by date for the last 30 days - const byDateLast30Days = aggregateCountsByDate( - organizationsCreatedLast30DaysRaw, - THIRTY_DAYS_AGO, - TODAY, - ); - - return NextResponse.json({ - countLast30Days, - count30To60DaysAgo, - changeLast30Days, - byDateLast30Days, - allTimeTotal, - }); - } catch (error) { - console.error("Error fetching organizations analytics:", error); - // Consistent error response - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/api/policies/route.ts b/apps/app/src/app/(internal)/internal/dashboard/api/policies/route.ts deleted file mode 100644 index 99e87021af..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/api/policies/route.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { db } from "@comp/db"; -import { PolicyStatus, Prisma } from "@prisma/client"; -import { NextRequest, NextResponse } from "next/server"; - -interface AssigneeDetails { - id: string; - name: string | null; - email: string | null; -} - -interface PolicyGroupByAssigneeResult { - assigneeId: string | null; - _count: { - _all: number; - }; -} - -interface PolicyGroupByDateResult { - createdAt: Date; - _count: { - _all: number; - }; -} - -interface DailyPolicyCount { - day: Date; - count: bigint; // Prisma $queryRaw returns bigint for COUNT -} - -const ANALYTICS_SECRET = process.env.ANALYTICS_SECRET || "dev_secret"; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const secret = searchParams.get("secret"); - - if (!ANALYTICS_SECRET || secret !== ANALYTICS_SECRET) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const sixtyDaysAgo = new Date(); - sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); - - const [ - allTimeTotal, - allTimePublished, - allTimeDraft, - last30DaysTotal, - last30DaysPublished, - last30DaysDraft, - last30DaysTotalByDayRaw, - previous30DaysTotal, - ] = await Promise.all([ - db.policy.count(), - db.policy.count({ - where: { status: PolicyStatus.published }, - }), - db.policy.count({ where: { status: PolicyStatus.draft } }), - db.policy.count({ - where: { - createdAt: { - gte: thirtyDaysAgo, - }, - }, - }), - db.policy.count({ - where: { - status: PolicyStatus.published, - createdAt: { - gte: thirtyDaysAgo, - }, - }, - }), - db.policy.count({ - where: { - status: PolicyStatus.draft, - createdAt: { - gte: thirtyDaysAgo, - }, - }, - }), - // Group by day for chart data (last 30 days) - db.$queryRaw` - SELECT - DATE_TRUNC('day', "createdAt") as day, - COUNT(*) as count - FROM "Policy" - WHERE "createdAt" >= ${thirtyDaysAgo} - GROUP BY DATE_TRUNC('day', "createdAt") - ORDER BY day ASC - `, - db.policy.count({ - // Policies created between 30 and 60 days ago - where: { - createdAt: { - gte: sixtyDaysAgo, - lt: thirtyDaysAgo, - }, - }, - }), - ]); - - // Prepare daily data, ensuring all days in the last 30 days are present - const last30DaysTotalByDayMap = new Map(); - const today = new Date(); - for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setDate(today.getDate() - i); - const dateString = date.toISOString().split("T")[0]; - last30DaysTotalByDayMap.set(dateString, 0); - } - - // Populate the map with actual counts from the database - for (const item of last30DaysTotalByDayRaw) { - // Ensure item.day is treated as UTC if it's not already - const itemDate = new Date(item.day); - // Adjust for potential timezone offset if item.day is local time - const itemUtcDate = new Date( - Date.UTC( - itemDate.getFullYear(), - itemDate.getMonth(), - itemDate.getDate(), - ), - ); - const dateString = itemUtcDate.toISOString().split("T")[0]; - if (last30DaysTotalByDayMap.has(dateString)) { - last30DaysTotalByDayMap.set(dateString, Number(item.count)); // Convert bigint to number - } - } - - const last30DaysTotalByDay = Array.from( - last30DaysTotalByDayMap.entries(), - ) - .map(([date, count]) => ({ date, count })) - .sort( - (a, b) => - new Date(a.date).getTime() - new Date(b.date).getTime(), - ); // Sort chronologically - - // Calculate percentage change - let percentageChangeLast30Days: number | null = null; - if (previous30DaysTotal > 0) { - percentageChangeLast30Days = - ((last30DaysTotal - previous30DaysTotal) / - previous30DaysTotal) * - 100; - } else if (last30DaysTotal > 0) { - percentageChangeLast30Days = null; // Indicate infinite change from 0 - } else { - percentageChangeLast30Days = 0; // No change from 0 to 0 - } - - return NextResponse.json({ - allTimeTotal, - allTimePublished, - allTimeDraft, - last30DaysTotal, - last30DaysPublished, - last30DaysDraft, - last30DaysTotalByDay, - percentageChangeLast30Days, - }); - } catch (error) { - console.error("Error fetching policy analytics:", error); - return NextResponse.json( - { error: "Failed to fetch policy analytics" }, - { status: 500 }, - ); - } -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/api/tasks/route.ts b/apps/app/src/app/(internal)/internal/dashboard/api/tasks/route.ts deleted file mode 100644 index 4c3e693b0f..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/api/tasks/route.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { db } from "@comp/db"; -import { TaskStatus } from "@prisma/client"; -import { NextRequest, NextResponse } from "next/server"; - -interface DailyTaskCount { - day: Date; - count: bigint; // Prisma $queryRaw returns bigint for COUNT -} - -const ANALYTICS_SECRET = process.env.ANALYTICS_SECRET || "dev_secret"; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const secret = searchParams.get("secret"); - - if (!ANALYTICS_SECRET || secret !== ANALYTICS_SECRET) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const sixtyDaysAgo = new Date(); - sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); - - const [ - allTimeTotal, - allTimeDone, // Renamed from allTimePublished - allTimeTodo, // Renamed from allTimeDraft - last30DaysTotal, - last30DaysDone, // Renamed from last30DaysPublished - last30DaysTodo, // Renamed from last30DaysDraft - last30DaysTotalByDayRaw, - previous30DaysTotal, - ] = await Promise.all([ - db.task.count(), - db.task.count({ - where: { status: TaskStatus.done }, - }), - db.task.count({ where: { status: TaskStatus.todo } }), - db.task.count({ - where: { createdAt: { gte: thirtyDaysAgo } }, - }), - db.task.count({ - where: { - createdAt: { gte: thirtyDaysAgo }, - status: TaskStatus.done, - }, - }), - db.task.count({ - where: { - createdAt: { gte: thirtyDaysAgo }, - status: TaskStatus.todo, - }, - }), - // Group by day for chart data (last 30 days) - db.$queryRaw` - SELECT - DATE_TRUNC('day', "createdAt")::date as day, - COUNT(*) as count - FROM "Task" - WHERE "createdAt" >= ${thirtyDaysAgo} - GROUP BY day - ORDER BY day ASC - `, - db.task.count({ - where: { - createdAt: { - gte: sixtyDaysAgo, - lt: thirtyDaysAgo, - }, - }, - }), - ]); - - // Process daily counts, ensuring all days in the last 30 days are present - const last30DaysTotalByDayMap = new Map(); - const today = new Date(); - for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setUTCDate(today.getUTCDate() - i); // Use UTC to avoid timezone issues - const dateString = date.toISOString().split("T")[0]; // YYYY-MM-DD format - last30DaysTotalByDayMap.set(dateString, 0); - } - - // Populate the map with actual counts from the database - for (const item of last30DaysTotalByDayRaw as DailyTaskCount[]) { - const itemDate = new Date(item.day); - const itemUtcDate = new Date( - Date.UTC( - itemDate.getFullYear(), - itemDate.getMonth(), - itemDate.getDate(), - ), - ); - const dateString = itemUtcDate.toISOString().split("T")[0]; - if (last30DaysTotalByDayMap.has(dateString)) { - last30DaysTotalByDayMap.set(dateString, Number(item.count)); - } - } - - // Convert map to sorted array - const last30DaysTotalByDay = Array.from( - last30DaysTotalByDayMap.entries(), - ) - .map(([date, count]) => ({ date, count })) - .sort( - (a, b) => - new Date(a.date).getTime() - new Date(b.date).getTime(), - ); // Sort chronologically - - // Calculate percentage change - let percentageChangeLast30Days: number | null = null; - if (previous30DaysTotal > 0) { - percentageChangeLast30Days = - ((last30DaysTotal - previous30DaysTotal) / - previous30DaysTotal) * - 100; - } else if (last30DaysTotal > 0) { - percentageChangeLast30Days = null; // Indicate infinite change from 0 - } else { - percentageChangeLast30Days = 0; // No change from 0 to 0 - } - - return NextResponse.json({ - allTimeTotal, - allTimeDone, // Renamed - allTimeTodo, // Renamed - last30DaysTotal, - last30DaysDone, // Renamed - last30DaysTodo, // Renamed - last30DaysTotalByDay, - percentageChangeLast30Days, - }); - } catch (error) { - console.error("Error fetching task analytics:", error); - return NextResponse.json( - { error: "Failed to fetch task analytics" }, - { status: 500 }, - ); - } -} \ No newline at end of file diff --git a/apps/app/src/app/(internal)/internal/dashboard/api/users/route.ts b/apps/app/src/app/(internal)/internal/dashboard/api/users/route.ts deleted file mode 100644 index 3c787b9aef..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/api/users/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { db } from "@comp/db"; -import { Prisma } from "@prisma/client"; -import { NextRequest, NextResponse } from "next/server"; - -// Types for groupBy results -// interface UsersGroupByDateResult { -// createdAt: Date; -// _count: { -// _all: number; -// }; -// } -interface DailyUserCount { - day: Date; - count: bigint; // Prisma $queryRaw returns bigint for COUNT -} - -const ANALYTICS_SECRET = process.env.ANALYTICS_SECRET; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const secret = searchParams.get("secret"); - - if (!ANALYTICS_SECRET || secret !== ANALYTICS_SECRET) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const sixtyDaysAgo = new Date(); - sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); - - const [ - allTimeTotal, - activeSessionTotal, - last30DaysTotal, - previous30DaysTotal, - last30DaysByDayRaw, - ] = await Promise.all([ - db.user.count(), // All time total users - db.user.count({ - // Active users (session not expired) - where: { - sessions: { some: { expiresAt: { gt: new Date() } } }, - }, - }), - db.user.count({ - // Users created in the last 30 days - where: { createdAt: { gte: thirtyDaysAgo } }, - }), - db.user.count({ - // Users created between 30 and 60 days ago - where: { - createdAt: { - gte: sixtyDaysAgo, - lt: thirtyDaysAgo, // Less than thirtyDaysAgo to avoid overlap - }, - }, - }), - db.$queryRaw` - SELECT - DATE_TRUNC('day', "createdAt") as day, - COUNT(*) as count - FROM "User" - WHERE "createdAt" >= ${thirtyDaysAgo} - GROUP BY DATE_TRUNC('day', "createdAt") - ORDER BY day ASC - `, - ]); - - // Prepare daily data, ensuring all days in the last 30 days are present - const last30DaysByDayMap = new Map(); - const today = new Date(); - for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setDate(today.getDate() - i); - const dateString = date.toISOString().split("T")[0]; - last30DaysByDayMap.set(dateString, 0); - } - - for (const item of last30DaysByDayRaw) { - const dateString = item.day.toISOString().split("T")[0]; - last30DaysByDayMap.set(dateString, Number(item.count)); // Convert bigint to number - } - - const last30DaysByDay = Array.from(last30DaysByDayMap.entries()) - .map(([date, count]) => ({ date, count })) - .sort( - (a, b) => - new Date(a.date).getTime() - new Date(b.date).getTime(), - ); // Sort chronologically - - // Calculate percentage change - let percentageChangeLast30Days: number | null = null; - if (previous30DaysTotal > 0) { - percentageChangeLast30Days = - ((last30DaysTotal - previous30DaysTotal) / - previous30DaysTotal) * - 100; - } else if (last30DaysTotal > 0) { - // If previous was 0 and current is > 0, change is infinite. - // Representing as null, but could also be a large number or specific indicator. - percentageChangeLast30Days = null; // Or potentially 100 if we want to show a 100% increase from 0 - } else { - // If both are 0, change is 0% - percentageChangeLast30Days = 0; - } - - return NextResponse.json({ - allTimeTotal, - last30DaysTotal, - last30DaysByDay, - activeSessionTotal, - percentageChangeLast30Days, - }); - } catch (error) { - console.error("Error fetching user analytics:", error); - return NextResponse.json( - { error: "Failed to fetch user analytics" }, - { status: 500 }, - ); - } -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/components/FullScreenNumberAnimation.tsx b/apps/app/src/app/(internal)/internal/dashboard/components/FullScreenNumberAnimation.tsx deleted file mode 100644 index b25feae5ce..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/components/FullScreenNumberAnimation.tsx +++ /dev/null @@ -1,183 +0,0 @@ -"use client"; - -import NumberFlow, { continuous } from "@number-flow/react"; -import { useEffect, useRef, useState } from "react"; -import { useDebounce } from "use-debounce"; - -interface FullScreenNumberAnimationProps { - total: number; -} - -const EFFECT_DURATION = 7000; - -// Radial pulse effect -function PulseEffect() { - return ( -
-
-
- ); -} - -export function FullScreenNumberAnimation({ - total, -}: FullScreenNumberAnimationProps) { - const [isVisible, setIsVisible] = useState(false); - const [isTransitioning, setIsTransitioning] = useState(false); - const [animationValue, setAnimationValue] = useState(total); - const [showEffects, setShowEffects] = useState(false); - const prevTotalRef = useRef(total); - const animationCompleteRef = useRef(false); - const audioRef = useRef(null); - - // Debounce value changes to handle rapid updates - const [debouncedTotal] = useDebounce(total, 300); - - // Initialize audio element - useEffect(() => { - // Create audio element only on client side - if (typeof window !== "undefined") { - audioRef.current = new Audio("/pulse.mp3"); - audioRef.current.preload = "auto"; - } - - return () => { - // Cleanup - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - }; - }, []); - - // Play sound effect - const playSound = () => { - if (audioRef.current) { - // Reset to beginning if already playing - audioRef.current.currentTime = 0; - - // Play the sound with a promise to handle autoplay restrictions - const playPromise = audioRef.current.play(); - - if (playPromise !== undefined) { - playPromise.catch((error) => { - console.error("Audio playback failed:", error); - }); - } - } - }; - - // Add custom animations to global styles - useEffect(() => { - const style = document.createElement("style"); - style.textContent = ` - @keyframes pulse-out { - 0% { transform: scale(0.5); opacity: 0.7; } - 100% { transform: scale(4); opacity: 0; } - } - .animate-pulse-out { - animation: pulse-out 2s cubic-bezier(0.16, 1, 0.3, 1) forwards; - } - `; - document.head.appendChild(style); - - return () => { - try { - document.head.removeChild(style); - } catch (e) { - console.error("Error removing style", e); - } - }; - }, []); - - // When debounced total changes, trigger the animation sequence - useEffect(() => { - if (debouncedTotal === prevTotalRef.current) return; - - // Reset animation state - animationCompleteRef.current = false; - - // Step 1: Show the fullscreen with previous value - setAnimationValue(prevTotalRef.current); - setIsVisible(true); - setShowEffects(false); - - // Step 2: After a delay, start the number transition - const startTransitionTimer = setTimeout(() => { - setIsTransitioning(true); - setAnimationValue(debouncedTotal); - - // Show visual effects when number starts transitioning - setTimeout(() => { - setShowEffects(true); - // Play sound when effects are shown - playSound(); - }, 100); - }, 500); - - // Step 3: Hide fullscreen after animation and a brief pause - const hideTimer = setTimeout(() => { - // Only hide if animation has completed - if (animationCompleteRef.current) { - setIsVisible(false); - setIsTransitioning(false); - setShowEffects(false); - prevTotalRef.current = debouncedTotal; - } - }, EFFECT_DURATION); - - return () => { - clearTimeout(startTransitionTimer); - clearTimeout(hideTimer); - }; - }, [debouncedTotal]); - - // Do not render anything if not visible - if (!isVisible) { - return null; - } - - // Render the full-screen animation overlay - return ( -
- {showEffects && } - -
- { - // Mark animation as complete - if (isTransitioning && animationValue === debouncedTotal) { - animationCompleteRef.current = true; - - // If we're past the hide timer duration, hide now - const timeElapsed = - Date.now() - - (prevTotalRef.current !== debouncedTotal - ? Date.now() - EFFECT_DURATION - : 0); - if (timeElapsed >= EFFECT_DURATION) { - setIsVisible(false); - setIsTransitioning(false); - setShowEffects(false); - prevTotalRef.current = debouncedTotal; - } - } - }} - /> -
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/components/OrganizationsCard.tsx b/apps/app/src/app/(internal)/internal/dashboard/components/OrganizationsCard.tsx deleted file mode 100644 index 61d260dc4f..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/components/OrganizationsCard.tsx +++ /dev/null @@ -1,172 +0,0 @@ -"use client"; - -import { Badge } from "@comp/ui/badge"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@comp/ui/chart"; -import { Skeleton } from "@comp/ui/skeleton"; -import { BarChart2, TrendingUp } from "lucide-react"; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; -import { useOrganizationsAnalytics } from "../hooks/useOrganizationsAnalytics"; -import { ValidatingSpinner } from "./ValidatingSpinner"; - -const chartConfig = { - organizations: { - label: "Organizations", - color: "hsl(151, 100%, 45%)", - }, -} satisfies ChartConfig; - -const chartFillColor = "hsl(var(--chart-positive))"; -const chartStrokeColor = "hsl(var(--chart-positive))"; - -export function OrganizationsCard() { - const { - data: orgsData, - isLoading: isOrgsLoading, - isError: isOrgsError, - isValidating: isOrgsValidating, - } = useOrganizationsAnalytics(); - - const chartData = orgsData?.byDateLast30Days ?? []; - const totalCountLast30Days = orgsData?.countLast30Days ?? 0; - const trendPercentage = orgsData?.changeLast30Days ?? 0; - const dateRange = "Last 30 days"; - - // Format the total count with thousands separators - const formattedTotalCount = new Intl.NumberFormat().format( - totalCountLast30Days, - ); - - if (isOrgsError) { - return ( -
-
-

Organizations

-

- Daily trend over the last 30 days -

-
-
-

Error loading data.

-
-
- ); - } - - return ( -
- {/* Header */} -
-
-
- -
-
-

Organizations

-

- Daily trend over the last 30 days -

-
-
- -
- - {/* Chart */} -
- {isOrgsLoading ? ( -
- - -
- ) : ( - - - value.toFixed(0)} - /> - - { - const date = new Date(value); - return date.toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - }); - }} - tickCount={10} - /> - - } - /> - - - - )} -
- - {/* Footer */} -
- {isOrgsLoading ? ( - - ) : ( -
-
- {formattedTotalCount} -
-
-
{dateRange}
- {trendPercentage !== undefined && ( - - - - {trendPercentage >= 0 ? "+" : ""} - {trendPercentage.toFixed(1)}% - - - )} -
-
- )} -
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/components/PoliciesCard.tsx b/apps/app/src/app/(internal)/internal/dashboard/components/PoliciesCard.tsx deleted file mode 100644 index bd0342cd95..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/components/PoliciesCard.tsx +++ /dev/null @@ -1,223 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; -import { Progress } from "@comp/ui/progress"; -import { Skeleton } from "@comp/ui/skeleton"; -import { - Calendar, - Clock, - ShieldCheck, - TrendingDown, - TrendingUp, -} from "lucide-react"; -import { useMemo } from "react"; -import { Line, LineChart, ResponsiveContainer } from "recharts"; -import { usePoliciesAnalytics } from "../hooks/usePoliciesAnalytics"; - -// Helper function for formatting numbers -function formatNumber(value: number | null | undefined): string { - if (value == null) return "N/A"; - return value.toLocaleString(); -} - -// Helper function for formatting percentages -function formatPercentage(value: number | null | undefined): string { - if (value == null) return "N/A"; - const sign = value >= 0 ? "+" : ""; - return `${sign}${value.toFixed(1)}%`; -} - -export function PoliciesCard() { - const { - data: policiesData, - isLoading: isPoliciesLoading, - isError: isPoliciesError, - } = usePoliciesAnalytics(); - - // Calculate chart data from the API data - use last 30 days daily data - const chartData = useMemo(() => { - if (!policiesData?.last30DaysTotalByDay) { - return []; - } - // Ensure data is sorted chronologically if not already guaranteed by API - return [...policiesData.last30DaysTotalByDay] - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - .map((item) => ({ - date: item.date, - value: item.count, - })); - }, [policiesData?.last30DaysTotalByDay]); - - // Calculate published percentage using all-time data - const policiesPublishedPercent = useMemo(() => { - if ( - policiesData?.allTimePublished != null && - policiesData?.allTimeTotal != null && - policiesData.allTimeTotal > 0 - ) { - return ( - (policiesData.allTimePublished / policiesData.allTimeTotal) * - 100 - ).toFixed(1); - } - return "0.0"; - }, [policiesData?.allTimePublished, policiesData?.allTimeTotal]); - - // Format growth percentage from API data - const growthPercentFormatted = useMemo(() => { - return formatPercentage(policiesData?.percentageChangeLast30Days); - }, [policiesData?.percentageChangeLast30Days]); - - const growthColor = useMemo(() => { - if (policiesData?.percentageChangeLast30Days == null) - return "text-muted-foreground"; - return policiesData.percentageChangeLast30Days >= 0 - ? "text-green-500" - : "text-destructive"; - }, [policiesData?.percentageChangeLast30Days]); - - if (isPoliciesError) { - return ( - - -
-
- -
- Policies -
-
- -

Error loading data.

-
-
- ); - } - - return ( - - -
-
- -
- Policies -
-
- {policiesData?.percentageChangeLast30Days == null || - policiesData.percentageChangeLast30Days >= 0 ? ( - - ) : ( - - )} - {isPoliciesLoading ? ( - - ) : ( - growthPercentFormatted - )} -
-
- - {isPoliciesLoading ? ( -
- {/* Chart Placeholder */} - {/* Last 30 days Placeholder */} -
- - - -
- {/* All Time Placeholder */} -
- - - - - - -
-
- ) : ( - <> -
-
- - - - - -
-
- - {/* Last 30 Days Section */} -
-
- - - Last 30 Days - -
-
- New - - {formatNumber(policiesData?.last30DaysTotal)} - -
-
- - {/* All Time Section */} -
-
- - - All Time - -
-
-
- Total - - {formatNumber(policiesData?.allTimeTotal) ?? "N/A"} - -
-
- -
-
- - Published - - - {formatNumber(policiesData?.allTimePublished) ?? "N/A"} - -
- -
- {policiesPublishedPercent}% of total -
-
-
- - )} -
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/components/TaskCard.tsx b/apps/app/src/app/(internal)/internal/dashboard/components/TaskCard.tsx deleted file mode 100644 index bf75c89630..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/components/TaskCard.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; -import { Progress } from "@comp/ui/progress"; -import { Skeleton } from "@comp/ui/skeleton"; -import { Calendar, Clock, FileText, TrendingUp } from "lucide-react"; -import { useMemo } from "react"; -import { Area, AreaChart, ResponsiveContainer } from "recharts"; -import { useTaskAnalytics } from "../hooks/useTaskAnalytics"; - -// Helper function for formatting numbers (assuming it exists or can be added) -function formatNumber(value: number | null | undefined): string { - if (value == null) return "N/A"; - return value.toLocaleString(); -} - -// Helper function for formatting percentages -function formatPercentage(value: number | null | undefined): string { - if (value == null) return "N/A"; - const sign = value >= 0 ? "+" : ""; - return `${sign}${value.toFixed(1)}%`; -} - -const chartStrokeColor = "#9333ea"; // purple-600 -const chartFillColor = "#9333ea"; // purple-600 - -export function TaskCard() { - const { - data: taskData, - isLoading: isTaskLoading, - isError: isTaskError, - } = useTaskAnalytics(); - - // Calculate chart data from the API data - use last 30 days daily data - const chartData = useMemo(() => { - if (!taskData?.last30DaysTotalByDay) { - return []; - } - // Ensure data is sorted chronologically if not already guaranteed by API - return [...taskData.last30DaysTotalByDay] - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - .map((item) => ({ - date: item.date, - value: item.count, - })); - }, [taskData?.last30DaysTotalByDay]); - - // Calculate published percentage using all-time data - const taskDonePercent = useMemo(() => { - if ( - taskData?.allTimeDone != null && - taskData?.allTimeTotal != null && - taskData.allTimeTotal > 0 - ) { - return ((taskData.allTimeDone / taskData.allTimeTotal) * 100).toFixed(1); - } - return "0.0"; - }, [taskData?.allTimeDone, taskData?.allTimeTotal]); - - // Format growth percentage from API data - const growthPercentFormatted = useMemo(() => { - return formatPercentage(taskData?.percentageChangeLast30Days); - }, [taskData?.percentageChangeLast30Days]); - - const growthColor = useMemo(() => { - if (taskData?.percentageChangeLast30Days == null) - return "text-muted-foreground"; - return taskData.percentageChangeLast30Days >= 0 - ? "text-green-500" - : "text-destructive"; - }, [taskData?.percentageChangeLast30Days]); - - if (isTaskError) { - return ( - - -
-
- -
- Task -
-
- -

Error loading data.

-
-
- ); - } - - return ( - - -
-
- -
- Task -
-
- - {isTaskLoading ? ( - - ) : ( - growthPercentFormatted - )} -
-
- - {isTaskLoading ? ( -
- {/* Chart Placeholder */} - {/* Last 30 days Placeholder */} -
- - - -
- {/* All Time Placeholder */} -
- - - - - - -
-
- ) : ( - <> -
-
- - - - - - - - - - - -
-
- - {/* Last 30 Days Section */} -
-
- - - Last 30 Days - -
-
- New - - {formatNumber(taskData?.last30DaysTotal)} - -
-
- - {/* All Time Section */} -
-
- - - All Time - -
-
-
- Total - - {formatNumber(taskData?.allTimeTotal) ?? "N/A"} - -
-
- -
-
- Done - - {formatNumber(taskData?.allTimeDone) ?? "N/A"} - -
- -
- {taskDonePercent}% of total -
-
-
- - )} -
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/components/UsersCard.tsx b/apps/app/src/app/(internal)/internal/dashboard/components/UsersCard.tsx deleted file mode 100644 index e7f056111c..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/components/UsersCard.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; -import { Progress } from "@comp/ui/progress"; -import { Skeleton } from "@comp/ui/skeleton"; -import { Calendar, Clock, TrendingDown, TrendingUp, Users } from "lucide-react"; -import { useMemo } from "react"; -import { Area, AreaChart, ResponsiveContainer } from "recharts"; -import { useUsersAnalytics } from "../hooks/useUsersAnalytics"; - -// Helper function for formatting numbers -function formatNumber(value: number | null | undefined): string { - if (value == null) return "N/A"; - return value.toLocaleString(); -} - -// Helper function for formatting percentages -function formatPercentage(value: number | null | undefined): string { - if (value == null) return "N/A"; - const sign = value >= 0 ? "+" : ""; - return `${sign}${value.toFixed(1)}%`; -} - -export function UsersCard() { - const { - data: usersData, - isLoading: isUsersLoading, - isError: isUsersError, - } = useUsersAnalytics(); - - // Calculate chart data from the API data - use last 30 days daily data - const chartData = useMemo(() => { - if (!usersData?.last30DaysByDay) { - return []; - } - // Ensure data is sorted chronologically - return [...usersData.last30DaysByDay] - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - .map((item) => ({ - date: item.date, - value: item.count, - })); - }, [usersData?.last30DaysByDay]); - - // Calculate active percentage using all-time data - const usersActivePercent = useMemo(() => { - if ( - usersData?.activeSessionTotal != null && - usersData?.allTimeTotal != null && - usersData.allTimeTotal > 0 - ) { - return ( - (usersData.activeSessionTotal / usersData.allTimeTotal) * - 100 - ).toFixed(1); - } - return "0.0"; - }, [usersData?.activeSessionTotal, usersData?.allTimeTotal]); - - // Format growth percentage from API data - const growthPercentFormatted = useMemo(() => { - return formatPercentage(usersData?.percentageChangeLast30Days); - }, [usersData?.percentageChangeLast30Days]); - - const growthColor = useMemo(() => { - if (usersData?.percentageChangeLast30Days == null) - return "text-muted-foreground"; - return usersData.percentageChangeLast30Days >= 0 - ? "text-green-500" - : "text-destructive"; - }, [usersData?.percentageChangeLast30Days]); - - if (isUsersError) { - return ( - - -
-
- -
- Users -
-
- -

Error loading data.

-
-
- ); - } - - return ( - - -
-
- -
- Users -
-
- {usersData?.percentageChangeLast30Days == null || - usersData.percentageChangeLast30Days >= 0 ? ( - - ) : ( - - )} - {isUsersLoading ? ( - - ) : ( - growthPercentFormatted - )} -
-
- - {isUsersLoading ? ( -
- {/* Chart Placeholder */} - {/* Last 30 days Placeholder */} -
- - -
- {/* All Time Placeholder */} -
- - - - - -
-
- ) : ( - <> -
-
- - - - - - - - - - - -
-
- - {/* Last 30 Days Section */} -
-
- - - Last 30 Days - -
-
- New - - {formatNumber(usersData?.last30DaysTotal)} - -
- {/* Removed 'Active' from last 30 days as API provides all-time active */} - {/*
- Active - {formatNumber(usersData?.activeSessionTotal)} // Incorrect context -
*/} -
- - {/* All Time Section */} -
-
- - - All Time - -
-
-
- Total - - {formatNumber(usersData?.allTimeTotal) ?? "N/A"} - -
-
- -
-
- Active - - {formatNumber(usersData?.activeSessionTotal) ?? "N/A"} - -
- -
- {usersActivePercent}% of total -
-
-
- - )} -
-
- ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/components/ValidatingSpinner.tsx b/apps/app/src/app/(internal)/internal/dashboard/components/ValidatingSpinner.tsx deleted file mode 100644 index e2f96a2280..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/components/ValidatingSpinner.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import { LogoSpinner } from "@/components/logo-spinner"; -import { cn } from "@comp/ui/cn"; -import { AnimatePresence, motion } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; - -interface ValidatingSpinnerProps { - isValidating: boolean; - delayMs?: number; - className?: string; -} - -export function ValidatingSpinner({ - isValidating, - delayMs = 0, - className, -}: ValidatingSpinnerProps) { - const [showSpinner, setShowSpinner] = useState(false); - const timerRef = useRef(null); - const minDisplayTimerRef = useRef(null); - const showTimeRef = useRef(null); - const hasShownRef = useRef(false); - const minDisplayDuration = 800; - - useEffect(() => { - if (isValidating && !showSpinner) { - // Clear any existing timers - if (timerRef.current) { - clearTimeout(timerRef.current); - } - - // Show spinner after delay - timerRef.current = setTimeout(() => { - setShowSpinner(true); - hasShownRef.current = true; - showTimeRef.current = Date.now(); - }, delayMs); - } else if (!isValidating && showSpinner) { - // For quick validations that trigger a show, keep visible for minimum time - if (hasShownRef.current) { - // Check if it's been shown for the minimum duration - if (showTimeRef.current) { - const timeVisible = Date.now() - showTimeRef.current; - - if (timeVisible < minDisplayDuration) { - // Not visible long enough, set timer to hide after remaining time - if (minDisplayTimerRef.current) { - clearTimeout(minDisplayTimerRef.current); - } - - minDisplayTimerRef.current = setTimeout(() => { - setShowSpinner(false); - hasShownRef.current = false; - showTimeRef.current = null; - }, minDisplayDuration - timeVisible); - - return; // Don't hide yet - } - } - - // Already shown for enough time, let AnimatePresence handle the exit animation - setShowSpinner(false); - hasShownRef.current = false; - showTimeRef.current = null; - } - } - - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - if (minDisplayTimerRef.current) { - clearTimeout(minDisplayTimerRef.current); - } - }; - }, [isValidating, showSpinner, delayMs, minDisplayDuration]); - - return ( - - {showSpinner && ( - - - - )} - - ); -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/config.ts b/apps/app/src/app/(internal)/internal/dashboard/config.ts deleted file mode 100644 index d68a6f4596..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/config.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const chartConfig = { - refreshIntervals: { - organizations: 30_000, - policies: 30_000, - users: 30_000, - task: 30_000, - }, -}; diff --git a/apps/app/src/app/(internal)/internal/dashboard/hooks/useAnalyticsSWRKeyWithSecret.ts b/apps/app/src/app/(internal)/internal/dashboard/hooks/useAnalyticsSWRKeyWithSecret.ts deleted file mode 100644 index 62444ab2bf..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/hooks/useAnalyticsSWRKeyWithSecret.ts +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; - -/** - * Custom hook to generate the SWR key for analytics endpoints, - * automatically appending the 'secret' query parameter if present. - * Returns null if the secret is not found, preventing SWR from fetching. - * - * @param apiEndpointBase The base path of the API endpoint (e.g., '/api/internal/dashboard/analytics/task') - * @returns The SWR key (URL string with secret or null) - */ -export function useAnalyticsSWRKeyWithSecret( - apiEndpointBase: string, -): string | null { - const searchParams = useSearchParams(); - const secret = searchParams.get("secret"); - - if (!secret) { - return null; // Don't fetch if secret is missing - } - - // Construct the key with the secret - return `${apiEndpointBase}?secret=${encodeURIComponent(secret)}`; -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/hooks/useOrganizationsAnalytics.ts b/apps/app/src/app/(internal)/internal/dashboard/hooks/useOrganizationsAnalytics.ts deleted file mode 100644 index 04345ab4a9..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/hooks/useOrganizationsAnalytics.ts +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { fetcher } from "@/utils/fetcher"; -import { useSearchParams } from "next/navigation"; -import useSWR from "swr"; -import { chartConfig } from "../config"; - -interface OrganizationsAnalyticsData { - countLast30Days: number; - count30To60DaysAgo: number; - changeLast30Days: number; - allTimeTotal: number; - byDateLast30Days: Array<{ - date: string; // YYYY-MM-DD format - count: number; - }>; -} - -const API_ENDPOINT = "/internal/dashboard/api/organizations"; - -export function useOrganizationsAnalytics() { - const searchParams = useSearchParams(); - const secret = searchParams.get("secret"); - - // Construct the key: null if secret is missing, otherwise the URL with secret - const key = secret - ? `${API_ENDPOINT}?secret=${encodeURIComponent(secret)}` - : null; - - const { data, error, isLoading, isValidating } = - useSWR( - key, // Use the conditional key - fetcher, - { - refreshInterval: chartConfig.refreshIntervals.organizations, - revalidateOnFocus: true, - revalidateOnReconnect: true, - }, - ); - - return { - data, - isLoading, - isError: error, - isValidating, - }; -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/hooks/usePoliciesAnalytics.ts b/apps/app/src/app/(internal)/internal/dashboard/hooks/usePoliciesAnalytics.ts deleted file mode 100644 index 6aab8fccad..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/hooks/usePoliciesAnalytics.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { fetcher } from "@/utils/fetcher"; -import useSWR from "swr"; -import { chartConfig } from "../config"; -import { useAnalyticsSWRKeyWithSecret } from "./useAnalyticsSWRKeyWithSecret"; - -interface PoliciesAnalyticsData { - allTimeTotal: number; - allTimePublished: number; - allTimeDraft: number; - last30DaysTotal: number; - last30DaysPublished: number; - last30DaysDraft: number; - last30DaysTotalByDay: Array<{ - date: string; // YYYY-MM-DD format - count: number; - }>; - percentageChangeLast30Days: number | null; -} - -const API_ENDPOINT = "/internal/dashboard/api/policies"; - -export function usePoliciesAnalytics() { - const key = useAnalyticsSWRKeyWithSecret(API_ENDPOINT); - - const { data, error, isLoading } = useSWR( - key, - fetcher, - { - refreshInterval: chartConfig.refreshIntervals.policies, - revalidateOnFocus: true, - revalidateOnReconnect: true, - }, - ); - - return { - data, - isLoading, - isError: error, - }; -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/hooks/useTaskAnalytics.ts b/apps/app/src/app/(internal)/internal/dashboard/hooks/useTaskAnalytics.ts deleted file mode 100644 index 6650a84076..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/hooks/useTaskAnalytics.ts +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { fetcher } from "@/utils/fetcher"; -import useSWR from "swr"; -import { chartConfig } from "../config"; -import { useAnalyticsSWRKeyWithSecret } from "./useAnalyticsSWRKeyWithSecret"; - -interface TaskAnalyticsData { - allTimeTotal: number; - allTimeDone: number; - allTimeTodo: number; - allTimeNotRelevant: number; - last30DaysTotal: number; - last30DaysDone: number; - last30DaysTodo: number; - last30DaysTotalByDay: Array<{ - date: string; - count: number; - }>; - percentageChangeLast30Days: number | null; -} - -const API_ENDPOINT = "/internal/dashboard/api/tasks"; - -export function useTaskAnalytics() { - const key = useAnalyticsSWRKeyWithSecret(API_ENDPOINT); - - const { data, error, isLoading } = useSWR(key, fetcher, { - refreshInterval: chartConfig.refreshIntervals.task, - revalidateOnFocus: true, - revalidateOnReconnect: true, - }); - - return { - data, - isLoading, - isError: error, - }; -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/hooks/useUsersAnalytics.ts b/apps/app/src/app/(internal)/internal/dashboard/hooks/useUsersAnalytics.ts deleted file mode 100644 index 1e54ab0848..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/hooks/useUsersAnalytics.ts +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { fetcher } from "@/utils/fetcher"; -import { useSearchParams } from "next/navigation"; -import useSWR from "swr"; -import { chartConfig } from "../config"; - -interface UsersAnalyticsData { - allTimeTotal: number; - last30DaysTotal: number; - last30DaysByDay: Array<{ - date: string; // YYYY-MM-DD format - count: number; - }>; - activeSessionTotal: number; - percentageChangeLast30Days: number | null; -} - -const API_ENDPOINT = "/internal/dashboard/api/users"; - -export function useUsersAnalytics() { - const searchParams = useSearchParams(); - const secret = searchParams.get("secret"); - - // Construct the key: null if secret is missing, otherwise the URL with secret - const key = secret - ? `${API_ENDPOINT}?secret=${encodeURIComponent(secret)}` - : null; - - const { data, error, isLoading } = useSWR( - key, // Use the conditional key - fetcher, - { - refreshInterval: chartConfig.refreshIntervals.users, - revalidateOnFocus: true, - revalidateOnReconnect: true, - }, - ); - - return { - data, - isLoading, - isError: error, - }; -} diff --git a/apps/app/src/app/(internal)/internal/dashboard/page.tsx b/apps/app/src/app/(internal)/internal/dashboard/page.tsx deleted file mode 100644 index 7b4af12953..0000000000 --- a/apps/app/src/app/(internal)/internal/dashboard/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { OrganizationsCard } from "./components/OrganizationsCard"; -import { PoliciesCard } from "./components/PoliciesCard"; -import { TaskCard } from "./components/TaskCard"; -import { UsersCard } from "./components/UsersCard"; - -export default function Page() { - return ( -
- {/* */} -
- -
-
- - - -
-
- ); -}