Added dynamic sidebar

This commit is contained in:
Mitchell Magro 2025-11-04 19:16:16 +01:00
parent b79f3afc2e
commit b3a666066c
17 changed files with 286 additions and 163 deletions

View File

@ -5,7 +5,7 @@ import { useEffect, useRef } from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { AppDispatch } from "@/app/redux/types"; import { AppDispatch } from "@/app/redux/types";
import { validateAuth } from "./redux/auth/authSlice"; import { validateAuth } from "./redux/auth/authSlice";
import { fetchMetadata } from "./redux/metadata/metadataSlice";
export function AuthBootstrap() { export function AuthBootstrap() {
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const startedRef = useRef(false); const startedRef = useRef(false);
@ -18,7 +18,7 @@ export function AuthBootstrap() {
// Initial validate on mount // Initial validate on mount
dispatch(validateAuth()); dispatch(validateAuth());
// dispatch(fetchMetadata());
// Refresh on window focus (nice UX) // Refresh on window focus (nice UX)
const onFocus = () => dispatch(validateAuth()); const onFocus = () => dispatch(validateAuth());
window.addEventListener("focus", onFocus); window.addEventListener("focus", onFocus);

View File

@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { randomUUID } from "crypto";
import { formatPhoneDisplay } from "@/app/features/UserRoles/utils"; import { formatPhoneDisplay } from "@/app/features/UserRoles/utils";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583"; const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";

View File

@ -20,21 +20,13 @@ export async function GET(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
const queryString = url.search ? url.search : ""; const queryString = url.search ? url.search : "";
console.log( const response = await fetch(`${BE_BASE_URL}/api/v1/users${queryString}`, {
"[DEBUG] - URL",
`${BE_BASE_URL}/api/v1/users/list${queryString}`
);
const response = await fetch(
`${BE_BASE_URL}/api/v1/users/list${queryString}`,
{
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
cache: "no-store", cache: "no-store",
} });
);
console.log("[DEBUG] - Response", response); console.log("[DEBUG] - Response", response);
const data = await response.json(); const data = await response.json();

48
app/api/metadata/route.ts Normal file
View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
// Proxy to backend metadata endpoint. Assumes BACKEND_BASE_URL is set.
export async function GET() {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
if (!token) {
return NextResponse.json(
{ success: false, message: "No token found" },
{ status: 401 }
);
}
const url = `${BE_BASE_URL.replace(/\/$/, "")}/api/v1/metadata`;
try {
const res = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const data = await res.json();
console.log("metadata", data);
if (!res.ok) {
return NextResponse.json(
{
success: false,
message: data?.message || "Failed to fetch metadata",
},
{ status: res.status }
);
}
return NextResponse.json(data);
} catch (err) {
const message = (err as Error)?.message || "Metadata proxy error";
return NextResponse.json({ success: false, message }, { status: 500 });
}
}

View File

@ -1,9 +1,9 @@
// app/components/PageLinks/PageLinks.tsx
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces"; // Keep this import
import clsx from "clsx"; // Utility to merge class names import clsx from "clsx"; // Utility to merge class names
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces"; // Keep this import
import { resolveIcon } from "@/app/utils/iconMap";
import "./PageLinks.scss"; // Keep this import import "./PageLinks.scss"; // Keep this import
// Define the props interface for your PageLinks component // Define the props interface for your PageLinks component
@ -13,19 +13,11 @@ interface IPageLinksProps extends ISidebarLink {
} }
// PageLinks component // PageLinks component
export default function PageLinks({ export default function PageLinks({ title, path, icon }: IPageLinksProps) {
title, const Icon = resolveIcon(icon);
path, console.log("Icon", Icon);
icon: Icon, // Destructure icon as Icon
}: // isShowIcon, // If you plan to use this prop, uncomment it and add logic
IPageLinksProps) {
return ( return (
// Corrected Link usage for Next.js 13/14 App Router:
// - Removed `passHref` and `legacyBehavior`
// - Applied `className` directly to the Link component
// - Removed the nested `<a>` tag
<Link href={path} className={clsx("page-link", "page-link__container")}> <Link href={path} className={clsx("page-link", "page-link__container")}>
{/* Conditionally render Icon if it exists */}
{Icon && <Icon />} {Icon && <Icon />}
<span className="page-link__text">{title}</span> <span className="page-link__text">{title}</span>
</Link> </Link>

View File

@ -49,11 +49,11 @@ export default async function BackOfficeUsersPage({
const data = await res.json(); const data = await res.json();
// Handle different response structures: could be array directly, or wrapped in data/users property // Handle different response structures: could be array directly, or wrapped in data/users property
// const users = Array.isArray(data) ? data : data.users || data.data || []; const users = Array.isArray(data) ? data : data.users || data.data || [];
return ( return (
<div> <div>
<Users users={data?.users} /> <Users users={users} />
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
export interface IUser { export interface IUser {
id: string; id: string;
email: string; email: string;
role: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
username: string; username: string;

View File

@ -7,13 +7,17 @@ import "./EditUser.scss";
const EditUser = ({ user }: { user: IUser }) => { const EditUser = ({ user }: { user: IUser }) => {
const router = useRouter(); const router = useRouter();
const { username, lastName, email, authorities: roles, phone } = user; const { username, last_name, email, groups, phone } = user;
const [form, setForm] = React.useState<IEditUserForm>({ const [form, setForm] = React.useState<IEditUserForm>({
firstName: username || "", firstName: username || "",
lastName: lastName || "", lastName: last_name || "",
email: email || "", email: email || "",
role: roles[0] || "", role: roles[0] || "",
phone: phone || "", phone: phone || "",
groups: groups || [],
merchants: merchants || [],
jobTitle: jobTitle || "",
username: username || "",
}); });
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -40,6 +44,10 @@ const EditUser = ({ user }: { user: IUser }) => {
email: "", email: "",
role: "", role: "",
phone: "", phone: "",
groups: [],
merchants: [],
jobTitle: "",
username: "",
}); });
}; };

View File

@ -6,9 +6,10 @@ import {
MenuItem, MenuItem,
SelectChangeEvent, SelectChangeEvent,
} from "@mui/material"; } from "@mui/material";
import { PAGE_LINKS } from "@/app/features/dashboard/sidebar/SidebarLink.constants";
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
import PageLinks from "../../../../components/PageLinks/PageLinks"; import PageLinks from "../../../../components/PageLinks/PageLinks";
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
import { useSelector } from "react-redux";
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
import "./DropDown.scss"; import "./DropDown.scss";
interface Props { interface Props {
@ -17,7 +18,7 @@ interface Props {
export default function SidebarDropdown({ onChange }: Props) { export default function SidebarDropdown({ onChange }: Props) {
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
const sidebar = useSelector(selectNavigationSidebar)[0]?.links;
const handleChange = (event: SelectChangeEvent<string>) => { const handleChange = (event: SelectChangeEvent<string>) => {
setValue(event.target.value); setValue(event.target.value);
onChange?.(event); onChange?.(event);
@ -42,8 +43,13 @@ export default function SidebarDropdown({ onChange }: Props) {
<em className="em">Select a page</em> <em className="em">Select a page</em>
<MenuItem value="" disabled></MenuItem> <MenuItem value="" disabled></MenuItem>
<div className="sidebar-dropdown__container"> <div className="sidebar-dropdown__container">
{PAGE_LINKS.map((link: ISidebarLink) => ( {sidebar?.map((link: SidebarItem) => (
<PageLinks key={link.path} title={link.title} path={link.path} /> <PageLinks
key={link.path}
title={link.title}
path={link.path}
icon={link.icon as string}
/>
))} ))}
</div> </div>
</Select> </Select>

View File

@ -1,14 +1,17 @@
"use client"; "use client";
import DashboardIcon from "@mui/icons-material/Dashboard"; import DashboardIcon from "@mui/icons-material/Dashboard";
import { useState } from "react"; import { ElementType, useState } from "react";
import { PAGE_LINKS } from "@/app/features/dashboard/sidebar/SidebarLink.constants";
import PageLinks from "../../../components/PageLinks/PageLinks"; import PageLinks from "../../../components/PageLinks/PageLinks";
import "./sideBar.scss"; import "./sideBar.scss";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
import { useSelector } from "react-redux";
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
import { resolveIcon } from "@/app/utils/iconMap";
interface SidebarProps { interface SidebarProps {
isOpen?: boolean; isOpen?: boolean;
@ -17,7 +20,7 @@ interface SidebarProps {
const SideBar = ({ isOpen = true, onClose }: SidebarProps) => { const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({}); const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const sidebar = useSelector(selectNavigationSidebar)[0]?.links;
const toggleMenu = (title: string) => { const toggleMenu = (title: string) => {
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] })); setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
}; };
@ -34,23 +37,26 @@ const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
</span> </span>
</div> </div>
{PAGE_LINKS.map(link => {sidebar?.map((link: SidebarItem) => {
link.children ? ( if (link.children) {
const Icon = resolveIcon(link.icon as string);
return (
<div key={link.title}> <div key={link.title}>
<button <button
onClick={() => toggleMenu(link.title)} onClick={() => toggleMenu(link.title)}
className="sidebar__dropdown-button" className="sidebar__dropdown-button"
> >
{link.icon && <link.icon />} {Icon && <Icon />}
<span className="sidebar__text">{link.title}</span> <span className="sidebar__text">{link.title}</span>
<span className="sidebar__arrow"> <span className="sidebar__arrow">
{!openMenus[link.title] ? ( {openMenus[link.title] ? (
<KeyboardArrowRightIcon />
) : (
<KeyboardArrowDownIcon /> <KeyboardArrowDownIcon />
) : (
<KeyboardArrowRightIcon />
)} )}
</span> </span>
</button> </button>
{openMenus[link.title] && ( {openMenus[link.title] && (
<div className="sidebar__submenu"> <div className="sidebar__submenu">
{link.children.map(child => ( {link.children.map(child => (
@ -58,21 +64,25 @@ const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
key={child.path} key={child.path}
title={child.title} title={child.title}
path={child.path} path={child.path}
icon={child.icon} icon={child.icon as ElementType}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
) : ( );
}
// Render simple links if no children
return (
<PageLinks <PageLinks
key={link.path} key={link.path}
title={link.title} title={link.title}
path={link.path} path={link.path}
icon={link.icon} icon={link.icon as ElementType}
/> />
) );
)} })}
</aside> </aside>
); );
}; };

View File

@ -1,84 +0,0 @@
import HomeIcon from "@mui/icons-material/Home";
import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import SearchIcon from "@mui/icons-material/Search";
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
import PeopleIcon from "@mui/icons-material/People";
import GavelIcon from "@mui/icons-material/Gavel";
import HubIcon from "@mui/icons-material/Hub";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import InsightsIcon from "@mui/icons-material/Insights";
import ListAltIcon from "@mui/icons-material/ListAlt";
import SettingsIcon from "@mui/icons-material/Settings";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import HistoryIcon from "@mui/icons-material/History";
import FactCheckIcon from "@mui/icons-material/FactCheck";
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
export const PAGE_LINKS: ISidebarLink[] = [
{ title: "Home", path: "/dashboard", icon: HomeIcon },
{
title: "Transaction",
path: "/dashboard/transactions",
icon: AccountBalanceWalletIcon,
children: [
{
title: "Deposits",
path: "/dashboard/transactions/deposits",
icon: ArrowDownwardIcon,
},
{
title: "Withdrawals",
path: "/dashboard/transactions/withdrawals",
icon: ArrowUpwardIcon,
},
{
title: "All Transactions",
path: "/dashboard/transactions/all",
icon: HistoryIcon,
},
],
},
{ title: "Approve", path: "/dashboard/approve", icon: CheckCircleIcon },
{ title: "Investigate", path: "/dashboard/investigate", icon: SearchIcon },
{ title: "KYC", path: "/dashboard/kyc", icon: VerifiedUserIcon },
{
title: "User Accounts",
path: "/dashboard/user-accounts",
icon: PeopleIcon,
},
// { title: 'Analytics', path: '/analytics', icon: BarChartIcon },
{ title: "Rules", path: "/dashboard/rules", icon: GavelIcon },
{ title: "Rules Hub", path: "/dashboard/rules-hub", icon: HubIcon },
{
title: "Admin",
path: "/dashboard/admin",
icon: AdminPanelSettingsIcon,
children: [
{
title: "Manage Users",
path: "/dashboard/admin/users",
icon: PeopleIcon,
},
{
title: "Transactions",
path: "/dashboard/admin/transactions",
icon: ListAltIcon,
},
{
title: "Settings",
path: "dashboard/admin/settings",
icon: SettingsIcon,
},
],
},
{ title: "Account IQ", path: "/dashboard/account-iq", icon: InsightsIcon },
{ title: "Audits", path: "/dashboard/audits", icon: FactCheckIcon },
// { title: 'Documentation', path: '/documentation', icon: DescriptionIcon },
// { title: 'Support', path: '/support', icon: SupportAgentIcon },
// { title: 'System Status', path: '/system-status', icon: WarningAmberIcon },
];

View File

@ -3,6 +3,6 @@ import { ElementType } from "react";
export interface ISidebarLink { export interface ISidebarLink {
title: string; title: string;
path: string; path: string;
icon?: ElementType; icon?: ElementType | string;
children?: ISidebarLink[]; children?: ISidebarLink[];
} }

View File

@ -3,6 +3,7 @@ import { ThunkSuccess, ThunkError, IUserResponse } from "../types";
import { IEditUserForm } from "../../features/UserRoles/User.interfaces"; import { IEditUserForm } from "../../features/UserRoles/User.interfaces";
import { RootState } from "../store"; import { RootState } from "../store";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { fetchMetadata } from "../metadata/metadataSlice";
interface TokenInfo { interface TokenInfo {
expiresAt: string; expiresAt: string;
@ -168,6 +169,7 @@ export const validateAuth = createAsyncThunk<
dispatch(autoLogout("Session invalid or expired")); dispatch(autoLogout("Session invalid or expired"));
return rejectWithValue("Session invalid or expired"); return rejectWithValue("Session invalid or expired");
} }
dispatch(fetchMetadata());
return { return {
tokenInfo: data.tokenInfo || null, tokenInfo: data.tokenInfo || null,

View File

@ -0,0 +1,90 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "../store";
export interface SidebarItem {
id?: string;
title: string;
path: string;
icon?: string; // icon name from backend; map client-side if needed
permissions?: string[]; // required permissions for visibility
children?: SidebarItem[];
}
export interface AppMetadata {
groups?: string[];
job_titles?: string[];
merchants?: string[];
message?: string;
sidebar?: SidebarItem[];
success: boolean;
}
interface MetadataState {
data: AppMetadata | null;
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
lastFetchedAt: number | null;
}
const initialState: MetadataState = {
data: null,
status: "idle",
error: null,
lastFetchedAt: null,
};
export const fetchMetadata = createAsyncThunk<
AppMetadata,
void,
{ rejectValue: string }
>("metadata/fetch", async (_, { rejectWithValue }) => {
try {
const res = await fetch("/api/metadata", {
method: "GET",
cache: "no-store",
});
const data = await res.json();
if (!res.ok) {
return rejectWithValue(data?.message || "Failed to fetch metadata");
}
return data as AppMetadata;
} catch (err) {
return rejectWithValue((err as Error)?.message || "Network error");
}
});
const metadataSlice = createSlice({
name: "metadata",
initialState,
reducers: {
clearMetadata(state) {
state.data = null;
state.error = null;
state.status = "idle";
state.lastFetchedAt = null;
},
},
extraReducers: builder => {
builder
.addCase(fetchMetadata.pending, state => {
state.status = "loading";
state.error = null;
})
.addCase(
fetchMetadata.fulfilled,
(state, action: PayloadAction<AppMetadata>) => {
state.status = "succeeded";
state.data = action.payload;
state.lastFetchedAt = Date.now();
}
)
.addCase(fetchMetadata.rejected, (state, action) => {
state.status = "failed";
state.error = (action.payload as string) || "Unknown error";
});
},
});
export const { clearMetadata } = metadataSlice.actions;
export default metadataSlice.reducer;

View File

@ -0,0 +1,7 @@
import { RootState } from "../store";
// Selectors
export const selectMetadataState = (state: RootState) => state.metadata;
export const selectAppMetadata = (state: RootState) => state.metadata.data;
export const selectNavigationSidebar = (state: RootState) =>
state.metadata.data?.sidebar || [];

View File

@ -9,6 +9,7 @@ import { createEpicMiddleware, combineEpics } from "redux-observable";
import advancedSearchReducer from "./advanedSearch/advancedSearchSlice"; import advancedSearchReducer from "./advanedSearch/advancedSearchSlice";
import authReducer from "./auth/authSlice"; import authReducer from "./auth/authSlice";
import uiReducer from "./ui/uiSlice"; import uiReducer from "./ui/uiSlice";
import metadataReducer from "./metadata/metadataSlice";
import userEpics from "./user/epic"; import userEpics from "./user/epic";
import authEpics from "./auth/epic"; import authEpics from "./auth/epic";
import uiEpics from "./ui/epic"; import uiEpics from "./ui/epic";
@ -38,6 +39,7 @@ const persistConfig = {
const rootReducer = combineReducers({ const rootReducer = combineReducers({
advancedSearch: advancedSearchReducer, advancedSearch: advancedSearchReducer,
auth: authReducer, auth: authReducer,
metadata: metadataReducer,
ui: uiReducer, ui: uiReducer,
}); });

50
app/utils/iconMap.ts Normal file
View File

@ -0,0 +1,50 @@
import type { ElementType } from "react";
// MUI icons used across the app; extend as needed
import HomeIcon from "@mui/icons-material/Home";
import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import SearchIcon from "@mui/icons-material/Search";
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
import PeopleIcon from "@mui/icons-material/People";
import GavelIcon from "@mui/icons-material/Gavel";
import HubIcon from "@mui/icons-material/Hub";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import InsightsIcon from "@mui/icons-material/Insights";
import ListAltIcon from "@mui/icons-material/ListAlt";
import SettingsIcon from "@mui/icons-material/Settings";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import HistoryIcon from "@mui/icons-material/History";
import FactCheckIcon from "@mui/icons-material/FactCheck";
import WidgetsIcon from "@mui/icons-material/Widgets"; // fallback
// Map string keys from backend to actual Icon components
const iconRegistry: Record<string, ElementType> = {
HomeIcon,
AccountBalanceWalletIcon,
CheckCircleIcon,
SearchIcon,
VerifiedUserIcon,
PeopleIcon,
GavelIcon,
HubIcon,
AdminPanelSettingsIcon,
InsightsIcon,
ListAltIcon,
SettingsIcon,
ArrowDownwardIcon,
ArrowUpwardIcon,
HistoryIcon,
FactCheckIcon,
};
export function resolveIcon(
icon?: ElementType | string
): ElementType | undefined {
if (!icon) return undefined;
if (typeof icon === "string") {
return iconRegistry[icon] || WidgetsIcon; // fallback to a generic icon
}
return icon;
}