diff --git a/frontend/_admin_analytics_page.txt b/frontend/_admin_analytics_page.txt new file mode 100644 index 00000000..339dfa40 --- /dev/null +++ b/frontend/_admin_analytics_page.txt @@ -0,0 +1,260 @@ +"use client"; + +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetAdminAnalytics } from "@/lib/react-query/hooks/admin/analytics/useGetAdminAnalytics"; +import { + TrendingUp, + BookOpen, + Users, + FileText, + MonitorCheck, + RefreshCw, + Trophy, +} from "lucide-react"; + +function formatNaira(kobo: number): string { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + notation: kobo >= 100_000_000 ? "compact" : "standard", + }).format(kobo / 100); +} + +function StatCard({ + label, + value, + sub, + icon: Icon, + color = "bg-gray-50 text-gray-500", +}: { + label: string; + value: string; + sub?: string; + icon: React.ElementType; + color?: string; +}) { + return ( +
+ + + +

{value}

+

{label}

+ {sub &&

{sub}

} +
+ ); +} + +export default function AdminAnalyticsPage() { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [appliedFrom, setAppliedFrom] = useState(); + const [appliedTo, setAppliedTo] = useState(); + + const { data, isLoading, refetch } = useGetAdminAnalytics( + appliedFrom, + appliedTo + ); + + const analytics = data?.data; + + const applyFilter = () => { + setAppliedFrom(from || undefined); + setAppliedTo(to || undefined); + }; + + const clearFilter = () => { + setFrom(""); + setTo(""); + setAppliedFrom(undefined); + setAppliedTo(undefined); + }; + + // Derive booking totals from byStatus map + const bookingsByStatus = analytics?.bookings.byStatus ?? {}; + const totalBookings = Object.values(bookingsByStatus).reduce( + (sum, n) => sum + n, + 0 + ); + + return ( + +
+
+

Analytics

+

+ Platform-wide business intelligence +

+
+ + {/* Date filter */} +
+ setFrom(e.target.value)} + className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + to + setTo(e.target.value)} + className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + + {(appliedFrom || appliedTo) && ( + + )} + +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ ) : !analytics ? ( +

+ No analytics data available. +

+ ) : ( +
+ {/* Revenue */} +
+

+ Revenue +

+
+ + + + +
+
+ + {/* Bookings */} +
+

+ Bookings +

+
+ + + + +
+
+ + {/* Occupancy */} +
+

+ Live occupancy +

+
+ + + +
+ + {/* Occupancy bar */} +
+
+ Seat utilisation + + {analytics.occupancy.occupiedSeats} /{" "} + {analytics.occupancy.totalSeats} + +
+
+
diff --git a/frontend/_admin_payments_page.txt b/frontend/_admin_payments_page.txt new file mode 100644 index 00000000..c5594fcf --- /dev/null +++ b/frontend/_admin_payments_page.txt @@ -0,0 +1,260 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useAuthState } from "@/lib/store/authStore"; +import { useGetAdminPayments } from "@/lib/react-query/hooks/admin/payments/useGetAdminPayments"; +import { useRefundPayment } from "@/lib/react-query/hooks/admin/payments/useRefundPayment"; +import { useGetAdminAnalytics } from "@/lib/react-query/hooks/admin/analytics/useGetAdminAnalytics"; +import { PaymentStatus, PaymentProvider } from "@/lib/types/payment"; +import { + CreditCard, + TrendingUp, + Clock, + XCircle, + ChevronLeft, + ChevronRight, + X, +} from "lucide-react"; + +const STATUS_TABS: { value: PaymentStatus | ""; label: string }[] = [ + { value: "", label: "All" }, + { value: "pending", label: "Pending" }, + { value: "success", label: "Success" }, + { value: "failed", label: "Failed" }, + { value: "refunded", label: "Refunded" }, +]; + +const PROVIDER_OPTIONS: { value: PaymentProvider | ""; label: string }[] = [ + { value: "", label: "All Providers" }, + { value: "paystack", label: "Paystack" }, + { value: "soroban", label: "Soroban" }, +]; + +const STATUS_COLORS: Record = { + pending: "bg-amber-50 text-amber-700", + success: "bg-emerald-50 text-emerald-600", + failed: "bg-red-50 text-red-600", + refunded: "bg-blue-50 text-blue-700", +}; + +function formatNaira(kobo: number): string { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + notation: kobo >= 100_000_000 ? "compact" : "standard", + }).format(kobo / 100); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-NG", { dateStyle: "medium" }); +} + +function StatCard({ + label, + value, + icon: Icon, + color, +}: { + label: string; + value: string; + icon: React.ElementType; + color: string; +}) { + return ( +
+ + + +

{value}

+

{label}

+
+ ); +} + +export default function AdminPaymentsPage() { + const router = useRouter(); + const { user } = useAuthState(); + + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + const [providerFilter, setProviderFilter] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [appliedFrom, setAppliedFrom] = useState(""); + const [appliedTo, setAppliedTo] = useState(""); + const [refundTarget, setRefundTarget] = useState(null); + + const isAdmin = user?.role === "admin"; + + useEffect(() => { + if (user && !isAdmin) { + router.replace("/dashboard"); + } + }, [user, isAdmin, router]); + + const { data, isLoading } = useGetAdminPayments({ + page, + limit: 20, + status: statusFilter || undefined, + provider: providerFilter || undefined, + from: appliedFrom || undefined, + to: appliedTo || undefined, + }); + + const { data: analyticsData } = useGetAdminAnalytics(); + + const { data: pendingData } = useGetAdminPayments({ status: "pending", limit: 1 }); + const { data: failedData } = useGetAdminPayments({ status: "failed", limit: 1 }); + + const refund = useRefundPayment(); + + if (!user || !isAdmin) return null; + + const payments = data?.data ?? []; + const meta = data?.meta; + const revenue = analyticsData?.data?.revenue; + + const handleApplyFilter = () => { + setAppliedFrom(from); + setAppliedTo(to); + setPage(1); + }; + + const handleClearFilter = () => { + setFrom(""); + setTo(""); + setAppliedFrom(""); + setAppliedTo(""); + setPage(1); + }; + + const handleRefund = async (id: string) => { + await refund.mutateAsync(id); + setRefundTarget(null); + }; + + return ( + +
+

Payments

+

+ {meta?.total ?? 0} total payments +

+
+ + {/* Stat cards */} +
+ + + + +
+ + {/* Filters */} +
+ {/* Provider dropdown */} +
+ + +
+ + {/* Date range */} +
+ + setFrom(e.target.value)} + className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300" + /> +
+
+ + setTo(e.target.value)} + className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300" + /> +
+ + {(appliedFrom || appliedTo) && ( + + )} +
+ + {/* Status filter tabs */} +
+ {STATUS_TABS.map(({ value, label }) => ( + + ))} +
+ + {/* Confirmation modal */} + {refundTarget && ( +
+
+

diff --git a/frontend/_analytics_hook.txt b/frontend/_analytics_hook.txt new file mode 100644 index 00000000..e0a7e88d --- /dev/null +++ b/frontend/_analytics_hook.txt @@ -0,0 +1,69 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; + +interface RevenueTrendPoint { + month: string; + totalKobo: number; + totalNaira: number; +} + +interface BookingTrendPoint { + month: string; + count: number; +} + +export interface AdminAnalytics { + revenue: { + total: number; + thisMonth: number; + lastMonth: number; + trend: RevenueTrendPoint[]; + }; + bookings: { + byStatus: Record; + trend: BookingTrendPoint[]; + }; + topWorkspaces: { + id: string; + name: string; + bookings: string; // raw string from SQL COUNT + revenueKobo: string; // raw string from SQL SUM + }[]; + topMembers: { + id: string; + fullName: string; + totalKobo: string; // raw string from SQL SUM + }[]; + invoices: { + total: number; + totalAmountKobo: number; + totalAmountNaira: number; + paid: number; + pending: number; + }; + occupancy: { + totalSeats: number; + occupiedSeats: number; + availableSeats: number; + occupancyPercent: number; + activeWorkspaces: number; + }; +} + +export const useGetAdminAnalytics = (from?: string, to?: string) => { + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + const qs = params.toString(); + + return useQuery({ + queryKey: queryKeys.admin.analytics({ from, to }), + queryFn: () => + apiClient.get<{ success: boolean; data: AdminAnalytics }>( + `/dashboard/admin/analytics${qs ? `?${qs}` : ""}` + ), + }); +}; diff --git a/frontend/_api_client.txt b/frontend/_api_client.txt new file mode 100644 index 00000000..bd9d4ade --- /dev/null +++ b/frontend/_api_client.txt @@ -0,0 +1,81 @@ +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:6001/api"; + +class ApiClient { + private baseURL: string; + private token: string | null = null; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + setToken(token: string | null) { + this.token = token; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseURL}${endpoint}`; + + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + if (this.token) { + headers["Authorization"] = `Bearer ${this.token}`; + } + + const config: RequestInit = { + ...options, + headers, + }; + + try { + const response = await fetch(url, config); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "An API error occurred"); + } + + return await response.json(); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error("Network error occurred"); + } + } + + async get(endpoint: string): Promise { + return this.request(endpoint, { + method: "GET", + }); + } + + async post(endpoint: string, data?: D): Promise { + return this.request(endpoint, { + method: "POST", + credentials: "include", + body: data ? JSON.stringify(data) : undefined, + }); + } + + async patch(endpoint: string, data?: D): Promise { + return this.request(endpoint, { + method: "PATCH", + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, { + method: "DELETE", + }); + } +} + +export const apiClient = new ApiClient(API_BASE_URL); diff --git a/frontend/_auth_store.txt b/frontend/_auth_store.txt new file mode 100644 index 00000000..8ecde49f --- /dev/null +++ b/frontend/_auth_store.txt @@ -0,0 +1,220 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/shallow"; +import { persist, devtools } from "zustand/middleware"; +import { + AuthResponse, + AuthState, + LoginUser, + RegisterUser, + User, +} from "../types/user"; +import { apiClient } from "../apiClient"; +import { storage } from "../storage"; + +interface AuthActions { + // Authentication actions + login: (data: LoginUser) => Promise; + register: (data: RegisterUser) => Promise; + logout: () => void; + refreshAccessToken: () => Promise; + + //User actions + updateProfile: (userData: Partial) => Promise; + + // State management actions + setUser: (user: User | null) => void; + setToken: (token: string | null) => void; + setLoading: (loading: boolean) => void; + initializeAuth: () => void; + clearAuth: () => void; +} + +type AuthStore = AuthState & AuthActions; + +export const useAuthStore = create()( + devtools( + persist( + (set, get) => ({ + // INITIAL STATE + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + + // AUTHENTICATION ACTIONS + login: async (data: LoginUser) => { + try { + set({ isLoading: true }); + + const response = await apiClient.post< + AuthResponse & { message?: string; requiresTwoFactor?: boolean; tempToken?: string } + >("/auth/login", data); + + const { user, accessToken, message, requiresTwoFactor, tempToken } = response; + + // If 2FA is required, surface a typed error so useLoginUser can redirect + if (requiresTwoFactor && tempToken) { + set({ isLoading: false }); + throw { twoFactorRequired: true, tempToken, email: data.email }; + } + + // If user is not verified, the backend returns a message without accessToken + if (!accessToken && message) { + set({ isLoading: false }); + throw { unverified: true, email: data.email, message }; + } + + // set the token in the api request headers authorization so that it always sends it to the backend when sending a request. + apiClient.setToken(accessToken); + + // update the local ui store state + set({ + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }); + + // persist token to local storage and cookie for middleware usage + storage.setToken(accessToken); + + // persist user to local storage + storage.setUser(user); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, + register: async (data: RegisterUser) => { + try { + set({ isLoading: true }); + + const { user, accessToken } = await apiClient.post( + "/auth/register", + data + ); + + apiClient.setToken(accessToken); + + set({ + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }); + + storage.setToken(accessToken); + storage.setUser(user); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, + logout: () => { + set({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }); + apiClient.setToken(null); + storage.clear(); + }, + refreshAccessToken: async () => { + try { + const currentToken = get().accessToken; + + if (!currentToken) { + throw new Error("No token available"); + } + + const response = await apiClient.post( + "/auth/refresh-token" + ); + + const { accessToken, user } = response; + + apiClient.setToken(accessToken); + + set({ + user, + accessToken, + isAuthenticated: true, + }); + + storage.setToken(accessToken); + storage.setUser(user); + } catch (error) { + get().logout(); + throw error; + } + }, + + // USER ACTIONS + updateProfile: async (userData: Partial) => { + try { + set({ isLoading: true }); + + const currentUser = get().user; + if (!currentUser) throw new Error("Not authenticated"); + + const user = await apiClient.patch( + `/users/${currentUser.id}`, + userData + ); + + set({ + user, + isLoading: false, + }); + + storage.setUser(user); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, + + // STATE MANAGEMENT ACTIONS + setUser: (user: User | null) => { + set({ user, isAuthenticated: !!user }); + }, + setToken: (accessToken: string | null) => { + set({ accessToken }); + apiClient.setToken(accessToken); + }, + setLoading: (loading: boolean) => { + set({ isLoading: loading }); + }, + initializeAuth: () => { + // Get the persisted data from localstorage + const accessToken = storage.getToken(); + const user = storage.getUser(); + + if (accessToken && user) { + apiClient.setToken(accessToken); + + set({ + user, + accessToken, + isAuthenticated: true, + }); + } + }, + clearAuth: () => { + set({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }); + apiClient.setToken(null); + storage.clear(); + }, + }), + { + name: "AuthStore", + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + isAuthenticated: state.isAuthenticated, diff --git a/frontend/_dashboard_sidebar.txt b/frontend/_dashboard_sidebar.txt new file mode 100644 index 00000000..088cc823 --- /dev/null +++ b/frontend/_dashboard_sidebar.txt @@ -0,0 +1,194 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Building2, + LayoutDashboard, + User, + Settings, + LogOut, + Users, + Mail, + Menu, + X, + BookOpen, + FileText, + BriefcaseBusiness, + LogIn, + Bell, + BarChart3, + CreditCard, +} from "lucide-react"; +import { useState } from "react"; +import { useAuthState, useAuthActions } from "@/lib/store/authStore"; +import NotificationBell from "@/components/notifications/NotificationBell"; + +const navItems = [ + { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, + { label: "Workspaces", href: "/workspaces", icon: BriefcaseBusiness }, + { label: "My Bookings", href: "/bookings", icon: BookOpen }, + { label: "Check In / Out", href: "/check-in", icon: LogIn }, + { label: "Notifications", href: "/notifications", icon: Bell }, + { label: "Invoices", href: "/invoices", icon: FileText }, + { label: "Profile", href: "/profile", icon: User }, + { label: "Settings", href: "/settings", icon: Settings }, +]; + +const adminItems = [ + { label: "Analytics", href: "/admin/analytics", icon: BarChart3 }, + { label: "Workspaces", href: "/admin/workspaces", icon: Building2 }, + { label: "All Bookings", href: "/admin/bookings", icon: BookOpen }, + { label: "Payments", href: "/admin/payments", icon: CreditCard }, + { label: "Members", href: "/admin/members", icon: Users }, + { label: "Invoices", href: "/admin/invoices", icon: FileText }, + { label: "Newsletter", href: "/dashboard?tab=newsletter", icon: Mail }, +]; + +export default function DashboardSidebar() { + const pathname = usePathname(); + const { user } = useAuthState(); + const { logout } = useAuthActions(); + const [mobileOpen, setMobileOpen] = useState(false); + const isAdmin = user?.role === "admin"; + + const handleLogout = () => { + logout(); + window.location.href = "/login"; + }; + + const sidebar = ( +
+ {/* Brand */} +
+ + + + + + ManageHub + + +
+ + {/* Nav */} + + + {/* User + Logout */} +
+
+ Notifications + +
+
+
+ {user?.firstname?.[0]} + {user?.lastname?.[0]} +
+
+

+ {user?.firstname} {user?.lastname} +

+

{user?.email}

+
+
+ +
+
+ ); + + return ( + <> + {/* Mobile hamburger */} + + + {/* Mobile overlay */} + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + {/* Mobile drawer */} +
+ + {sidebar} +
+ + {/* Desktop sidebar */} + + + ); +}