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

View File

@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { randomUUID } from "crypto";
import { formatPhoneDisplay } from "@/app/features/UserRoles/utils";
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 queryString = url.search ? url.search : "";
console.log(
"[DEBUG] - URL",
`${BE_BASE_URL}/api/v1/users/list${queryString}`
);
const response = await fetch(
`${BE_BASE_URL}/api/v1/users/list${queryString}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
}
);
const response = await fetch(`${BE_BASE_URL}/api/v1/users${queryString}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
console.log("[DEBUG] - Response", response);
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";
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 { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces"; // Keep this import
import { resolveIcon } from "@/app/utils/iconMap";
import "./PageLinks.scss"; // Keep this import
// Define the props interface for your PageLinks component
@ -13,19 +13,11 @@ interface IPageLinksProps extends ISidebarLink {
}
// PageLinks component
export default function PageLinks({
title,
path,
icon: Icon, // Destructure icon as Icon
}: // isShowIcon, // If you plan to use this prop, uncomment it and add logic
IPageLinksProps) {
export default function PageLinks({ title, path, icon }: IPageLinksProps) {
const Icon = resolveIcon(icon);
console.log("Icon", Icon);
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")}>
{/* Conditionally render Icon if it exists */}
{Icon && <Icon />}
<span className="page-link__text">{title}</span>
</Link>

View File

@ -49,11 +49,11 @@ export default async function BackOfficeUsersPage({
const data = await res.json();
// 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 (
<div>
<Users users={data?.users} />
<Users users={users} />
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { ThunkSuccess, ThunkError, IUserResponse } from "../types";
import { IEditUserForm } from "../../features/UserRoles/User.interfaces";
import { RootState } from "../store";
import toast from "react-hot-toast";
import { fetchMetadata } from "../metadata/metadataSlice";
interface TokenInfo {
expiresAt: string;
@ -168,6 +169,7 @@ export const validateAuth = createAsyncThunk<
dispatch(autoLogout("Session invalid or expired"));
return rejectWithValue("Session invalid or expired");
}
dispatch(fetchMetadata());
return {
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 authReducer from "./auth/authSlice";
import uiReducer from "./ui/uiSlice";
import metadataReducer from "./metadata/metadataSlice";
import userEpics from "./user/epic";
import authEpics from "./auth/epic";
import uiEpics from "./ui/epic";
@ -38,6 +39,7 @@ const persistConfig = {
const rootReducer = combineReducers({
advancedSearch: advancedSearchReducer,
auth: authReducer,
metadata: metadataReducer,
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;
}