diff --git a/netmanager-app/.gitignore b/netmanager-app/.gitignore index 873dc7060b..29d0aa3ffb 100644 --- a/netmanager-app/.gitignore +++ b/netmanager-app/.gitignore @@ -41,6 +41,10 @@ build/Release node_modules/ jspm_packages/ +# Removing the idea +.idea/ + + # TypeScript v1 declaration files typings/ diff --git a/netmanager-app/app/(authenticated)/layout.tsx b/netmanager-app/app/(authenticated)/layout.tsx index a7cd0b932a..eb6a224a0b 100644 --- a/netmanager-app/app/(authenticated)/layout.tsx +++ b/netmanager-app/app/(authenticated)/layout.tsx @@ -2,7 +2,6 @@ import "../globals.css"; import Layout from "../../components/layout"; -import { Toaster } from "@/components/ui/sonner"; export default function AuthenticatedLayout({ children, @@ -12,7 +11,6 @@ export default function AuthenticatedLayout({ return ( {children} - ); } diff --git a/netmanager-app/app/(authenticated)/organizations/[id]/page.tsx b/netmanager-app/app/(authenticated)/organizations/[id]/page.tsx new file mode 100644 index 0000000000..b80dfa3956 --- /dev/null +++ b/netmanager-app/app/(authenticated)/organizations/[id]/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type React from "react"; +import { Suspense } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { OrganizationProfile } from "@/components/Organization/organization-profile"; +import { TeamMembers } from "@/components/Organization/team-members"; +import { OrganizationRoles } from "@/components/Organization/organization-roles"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ArrowLeftIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { RouteGuard } from "@/components/route-guard"; + +const LoadingFallback = () => ( +
+ + +
+); + +const TabContent = ({ + value, + children, +}: { + value: string; + children: React.ReactNode; +}) => ( + + }>{children} + +); + +const OrganizationDetailsPage = ({ params }: { params: { id: string } }) => { + const router = useRouter(); + + return ( + +
+ {/* Back button */} + + {/* Organization Details */} +

Organization Details

+ + + Organization Profile + Team Members + Organization Roles + + + + + + + + + + + +
+
+ ); +}; + +export default OrganizationDetailsPage; diff --git a/netmanager-app/app/(authenticated)/organizations/page.tsx b/netmanager-app/app/(authenticated)/organizations/page.tsx new file mode 100644 index 0000000000..ca15579218 --- /dev/null +++ b/netmanager-app/app/(authenticated)/organizations/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { OrganizationList } from "@/components/Organization/List"; +import { RouteGuard } from "@/components/route-guard"; + +const OrganizationSettingsPage = () => { + return ( + +
+ +
+
+ ); +}; + +export default OrganizationSettingsPage; diff --git a/netmanager-app/app/layout.tsx b/netmanager-app/app/layout.tsx index 25716d6d52..982b921dad 100644 --- a/netmanager-app/app/layout.tsx +++ b/netmanager-app/app/layout.tsx @@ -1,6 +1,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import Providers from "./providers"; +import { Toaster } from "@/components/ui/sonner"; const inter = Inter({ subsets: ["latin"] }); @@ -13,6 +14,7 @@ export default function RootLayout({ {children} + ); diff --git a/netmanager-app/app/types/groups.ts b/netmanager-app/app/types/groups.ts new file mode 100644 index 0000000000..42ade0e337 --- /dev/null +++ b/netmanager-app/app/types/groups.ts @@ -0,0 +1,51 @@ +import { UserDetails } from "@/app/types/users"; + + export interface Group { + _id: string + grp_status: "ACTIVE" | "INACTIVE" + grp_profile_picture: string + grp_title: string + grp_description: string + grp_website: string + grp_industry: string + grp_country: string + grp_timezone: string + createdAt: string + numberOfGroupUsers: number + grp_users: UserDetails[] + grp_manager: UserDetails + } + + export interface GroupResponse { + success: boolean + message: string + group: Group + } + +interface RolePermission { + _id: string; + permission: string; + }; + +export interface GroupMember { + _id: string; + email: string; + firstName: string; + lastName: string; + userName: string; + profilePicture: string; + jobTitle: string; + isActive: boolean; + lastLogin: string; + createdAt: string; + role_name: string; + role_id: string; + role_permissions: RolePermission[]; + }; + +export interface GroupMembersResponse { + success: boolean; + message: string; + group_members: GroupMember[]; + }; + \ No newline at end of file diff --git a/netmanager-app/app/types/roles.ts b/netmanager-app/app/types/roles.ts new file mode 100644 index 0000000000..c9c714f229 --- /dev/null +++ b/netmanager-app/app/types/roles.ts @@ -0,0 +1,38 @@ +export interface Permission { + _id: string; + permission: string; + } + +export interface Group { + _id: string; + grp_status: string; + grp_profile_picture: string; + grp_title: string; + grp_description: string; + grp_website: string; + grp_industry: string; + grp_country: string; + grp_timezone: string; + grp_manager: string; + grp_manager_username: string; + grp_manager_firstname: string; + grp_manager_lastname: string; + createdAt: string; + updatedAt: string; + __v: number; + } + +export interface Role { + _id: string; + role_status: string; + role_name: string; + role_permissions: Permission[]; + group?: Group; + } + +export interface RolesResponse { + success: boolean; + message: string; + roles: Role[]; + } + \ No newline at end of file diff --git a/netmanager-app/components/Organization/List.tsx b/netmanager-app/components/Organization/List.tsx new file mode 100644 index 0000000000..85d069488d --- /dev/null +++ b/netmanager-app/components/Organization/List.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useState } from "react"; +import { Search, Loader2, ArrowUpDown, Eye } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { useGroups } from "@/core/hooks/useGroups"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import Link from "next/link"; +import { CreateOrganizationDialog } from "./create-group"; + +const ITEMS_PER_PAGE = 8; + +type SortField = + | "grp_title" + | "grp_industry" + | "numberOfGroupUsers" + | "createdAt"; +type SortOrder = "asc" | "desc"; + +const formatTitle = (title: string) => { + return title + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +}; + +export function OrganizationList() { + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [sortField, setSortField] = useState("createdAt"); + const [sortOrder, setSortOrder] = useState("desc"); + const { groups, isLoading, error } = useGroups(); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortOrder("asc"); + } + }; + + const sortOrganizations = (orgsToSort: any[]) => { + return [...orgsToSort].sort((a, b) => { + if (sortField === "createdAt") { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return sortOrder === "asc" ? dateA - dateB : dateB - dateA; + } + + let compareA = a[sortField]; + let compareB = b[sortField]; + + if (!compareA || !compareB) { + return sortOrder === "asc" ? -1 : 1; + } + + if (typeof compareA === "string") { + compareA = formatTitle(compareA).toLowerCase(); + compareB = formatTitle(compareB).toLowerCase(); + } + + if (compareA < compareB) return sortOrder === "asc" ? -1 : 1; + if (compareA > compareB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + }; + + const filteredOrganizations = groups.filter((org) => + org.grp_title.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const sortedOrganizations = sortOrganizations(filteredOrganizations); + + const totalPages = Math.ceil(sortedOrganizations.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const currentOrganizations = sortedOrganizations.slice(startIndex, endIndex); + + const getPageNumbers = () => { + const pageNumbers = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + for (let i = 1; i <= totalPages; i++) { + pageNumbers.push(i); + } + } else { + if (currentPage <= 3) { + for (let i = 1; i <= 4; i++) { + pageNumbers.push(i); + } + pageNumbers.push("ellipsis"); + pageNumbers.push(totalPages); + } else if (currentPage >= totalPages - 2) { + pageNumbers.push(1); + pageNumbers.push("ellipsis"); + for (let i = totalPages - 3; i <= totalPages; i++) { + pageNumbers.push(i); + } + } else { + pageNumbers.push(1); + pageNumbers.push("ellipsis"); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pageNumbers.push(i); + } + pageNumbers.push("ellipsis"); + pageNumbers.push(totalPages); + } + } + return pageNumbers; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + + Error + {error.message} + +
+ ); + } + + return ( +
+
+

Organizations

+ +
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + + + + + handleSort("createdAt")}> + Date Created{" "} + {sortField === "createdAt" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("grp_title")}> + Name{" "} + {sortField === "grp_title" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("grp_industry")}> + Industry{" "} + {sortField === "grp_industry" && + (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("numberOfGroupUsers")}> + Users{" "} + {sortField === "numberOfGroupUsers" && + (sortOrder === "asc" ? "↑" : "↓")} + + + +
+ +
+ + + + handleSort("grp_title")} + > + Name{" "} + {sortField === "grp_title" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("grp_industry")} + > + Industry{" "} + {sortField === "grp_industry" && + (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("numberOfGroupUsers")} + > + Users{" "} + {sortField === "numberOfGroupUsers" && + (sortOrder === "asc" ? "↑" : "↓")} + + Actions + + + + {currentOrganizations.map((org) => ( + + + {formatTitle(org.grp_title)} + + + + {org.grp_industry ? org.grp_industry : "Not provided"} + + + {org.numberOfGroupUsers} + +
+ +
+
+
+ ))} + {currentOrganizations.length === 0 && ( + + + No organizations found + + + )} +
+
+
+ + {sortedOrganizations.length > 0 && ( +
+ + + + + setCurrentPage((prev) => Math.max(prev - 1, 1)) + } + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + {getPageNumbers().map((pageNumber, index) => ( + + {pageNumber === "ellipsis" ? ( + + ) : ( + setCurrentPage(pageNumber as number)} + isActive={currentPage === pageNumber} + className="cursor-pointer" + > + {pageNumber} + + )} + + ))} + + + + setCurrentPage((prev) => Math.min(prev + 1, totalPages)) + } + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + +
+ )} +
+ ); +} diff --git a/netmanager-app/components/Organization/create-group.tsx b/netmanager-app/components/Organization/create-group.tsx new file mode 100644 index 0000000000..ae0eb5e760 --- /dev/null +++ b/netmanager-app/components/Organization/create-group.tsx @@ -0,0 +1,723 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/components/ui/use-toast"; +import { Plus, Loader2, ChevronLeft, Check, Users } from "lucide-react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useFieldArray } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import TimezoneSelect, { allTimezones } from "react-timezone-select"; +import countryList from "react-select-country-list"; +import { Card, CardContent } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + useCreateGroup, + useAssignDevicesToGroup, + useAssignSitesToGroup, + useInviteUserToGroup, +} from "@/core/hooks/useGroups"; +import { useSites } from "@/core/hooks/useSites"; +import { useDevices } from "@/core/hooks/useDevices"; +import { Textarea } from "@/components/ui/textarea"; + +const createOrgSchema = z.object({ + grp_title: z + .string() + .min(2, { message: "Organization name must be at least 2 characters." }), + grp_country: z.string({ required_error: "Please select a country." }), + grp_industry: z.string({ required_error: "Please select an industry." }), + grp_timezone: z.object( + { value: z.string(), label: z.string() }, + { required_error: "Please select a timezone." } + ), + grp_description: z.string().min(1, { message: "Description is required." }), + grp_website: z + .string() + .url({ message: "Please enter a valid URL." }) + .min(1, { message: "Website is required." }), + grp_profile_picture: z + .string() + .url({ message: "Please enter a valid URL for the profile picture." }) + .optional() + .or(z.literal("")), +}); + +const addSitesSchema = z.object({ + sites: z.array( + z.object({ + _id: z.string(), + name: z.string(), + groups: z.array(z.string()), + }) + ), +}); + +const addDevicesSchema = z.object({ + devices: z.array( + z.object({ + _id: z.string(), + name: z.string(), + groups: z.array(z.string()), + }) + ), +}); + +const inviteMembersSchema = z.object({ + members: z.array( + z.object({ + email: z + .string() + .email({ message: "Please enter a valid email address." }), + }) + ), +}); + +const industries = [ + { value: "Manufacturing", label: "Manufacturing" }, + { value: "Information Technology", label: "Information Technology" }, + { value: "Healthcare", label: "Healthcare" }, + { value: "Finance", label: "Finance" }, + { value: "Education", label: "Education" }, + { value: "Retail", label: "Retail" }, + { value: "Other", label: "Other" }, +]; + +export function CreateOrganizationDialog() { + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const [currentStep, setCurrentStep] = useState(1); + + const countries = countryList().getData(); + + const createOrgForm = useForm>({ + resolver: zodResolver(createOrgSchema), + defaultValues: { + grp_title: "", + grp_country: "", + grp_industry: "", + grp_timezone: { value: "", label: "" }, + grp_description: "", + grp_website: "", + grp_profile_picture: "", + }, + }); + + const addSitesForm = useForm>({ + resolver: zodResolver(addSitesSchema), + defaultValues: { + sites: [], + }, + }); + + const addDevicesForm = useForm>({ + resolver: zodResolver(addDevicesSchema), + defaultValues: { + devices: [], + }, + }); + + const inviteMembersForm = useForm>({ + resolver: zodResolver(inviteMembersSchema), + defaultValues: { + members: [{ email: "" }], + }, + }); + + const { + fields: memberFields, + append: appendMember, + remove: removeMember, + } = useFieldArray({ + control: inviteMembersForm.control, + name: "members", + }); + + const { mutate: createGroup, isLoading: isCreatingGroup } = useCreateGroup(); + const { mutate: assignDevicesToGroup, isLoading: isAssigningDevices } = + useAssignDevicesToGroup(); + const { mutate: assignSitesToGroup, isLoading: isAssigningSites } = + useAssignSitesToGroup(); + const { mutate: inviteUserToGroup, isLoading: isInvitingMembers } = + useInviteUserToGroup(""); + + const { sites: allSites, isLoading: isLoadingSites } = useSites(); + const { devices: allDevices, isLoading: isLoadingDevices } = useDevices(); + + const onCreateOrganization = async ( + data: z.infer + ) => { + try { + await createGroup(data); + toast({ + title: "Organization created", + description: `Successfully created organization: ${data.grp_title}`, + }); + setCurrentStep(2); + } catch (error) { + toast({ + title: "Error", + description: `Failed to create organization: ${ + (error as Error).message + }`, + variant: "destructive", + }); + console.error("Error creating organization:", error); + } + }; + + const onAddSites = async (data: z.infer) => { + const groupId = createOrgForm.getValues().grp_title; + try { + await assignSitesToGroup({ + siteIds: data.sites.map((site) => site._id), + groups: [groupId], + }); + toast({ + title: "Sites added", + description: + "The sites have been successfully added to the organization.", + }); + setCurrentStep(3); + } catch (error) { + toast({ + title: "Error", + description: `Failed to add sites: ${(error as Error).message}`, + variant: "destructive", + }); + } + }; + + const onAddDevices = async (data: z.infer) => { + const groupId = createOrgForm.getValues().grp_title; + try { + await assignDevicesToGroup({ + deviceIds: data.devices.map((device) => device._id), + groups: [groupId], + }); + toast({ + title: "Devices added", + description: + "The devices have been successfully added to the organization.", + }); + setCurrentStep(4); + } catch (error) { + toast({ + title: "Error", + description: `Failed to add devices: ${(error as Error).message}`, + variant: "destructive", + }); + } + }; + + const onInviteMembers = async (data: z.infer) => { + // const groupId = createOrgForm.getValues().grp_title + try { + for (const member of data.members) { + await inviteUserToGroup(member.email); + } + toast({ + title: "Invitations sent", + description: "Successfully sent invitations to the new members.", + }); + setOpen(false); + resetForms(); + } catch (error) { + toast({ + title: "Error", + description: `Failed to invite members: ${(error as Error).message}`, + variant: "destructive", + }); + } + }; + + const resetForms = () => { + createOrgForm.reset(); + addSitesForm.reset(); + addDevicesForm.reset(); + inviteMembersForm.reset(); + setCurrentStep(1); + }; + + const renderStep = () => { + if (isLoadingSites || isLoadingDevices) { + return ( +
+ +
+ ); + } + + switch (currentStep) { + case 1: + return ( +
+ + ( + + Organization Name + + + + + + )} + /> + ( + + Country + + + + )} + /> + ( + + Industry + + + + )} + /> + ( + + Timezone + + + + + + )} + /> + ( + + Description + +