admin updates #3

Merged
Brook-Tewabe-Yaltopia merged 1 commits from el-ui into prod 2026-06-12 12:22:42 +03:00
12 changed files with 734 additions and 159 deletions

View File

@ -1,15 +1,16 @@
import { Navigate, useLocation } from "react-router-dom";
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
// const location = useLocation()
// const token = localStorage.getItem('access_token')
const location = useLocation();
const token = localStorage.getItem("access_token");
// if (!token) {
// // Redirect to login page with return URL
// return <Navigate to="/login" state={{ from: location }} replace />
// }
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@ -40,7 +40,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { roleLabel, getPermissions } from "@/lib/admin-roles";
import { authService } from "@/services";
import { authService, notificationService } from "@/services";
interface User {
email: string;
@ -49,6 +50,7 @@ interface User {
role: string;
}
type NavItem = {
icon?: ComponentType<{ className?: string }>;
label: string;
@ -58,6 +60,7 @@ type NavItem = {
visible?: (role: string | undefined) => boolean;
};
function navItemIsActive(
item: NavItem,
isActive: (path?: string) => boolean,
@ -66,6 +69,7 @@ function navItemIsActive(
return item.children?.some((child) => navItemIsActive(child, isActive)) ?? false;
}
function filterNavItems(
items: NavItem[],
role: string | undefined,
@ -83,6 +87,7 @@ function filterNavItems(
.filter((item) => !item.children || item.children.length > 0);
}
const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{
@ -208,6 +213,7 @@ const adminNavigationItems: NavItem[] = [
},
];
const SidebarNavItem = ({
item,
depth = 0,
@ -225,8 +231,10 @@ const SidebarNavItem = ({
const hasChildren = visibleChildren && visibleChildren.length > 0;
const isCurrentlyActive = navItemIsActive(item, isActive);
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
// Keep open if it becomes active from external navigation (e.g. breadcrumbs or search)
useEffect(() => {
if (isCurrentlyActive) {
@ -234,8 +242,10 @@ const SidebarNavItem = ({
}
}, [isCurrentlyActive]);
const Icon = item.icon;
if (hasChildren) {
return (
<div className="space-y-1">
@ -276,6 +286,7 @@ const SidebarNavItem = ({
);
}
return (
<Link
to={item.path || "#"}
@ -293,10 +304,12 @@ const SidebarNavItem = ({
);
};
export function AppShell() {
const location = useLocation();
const navigate = useNavigate();
// Initialize user from localStorage
const [user] = useState<User | null>(() => {
const userStr = localStorage.getItem("user");
@ -311,24 +324,53 @@ export function AppShell() {
return null;
});
// ✅ NEW: Track unread notification count
const [unreadCount, setUnreadCount] = useState<number>(0);
const isActive = (path?: string) => {
if (!path) return false;
return location.pathname.startsWith(path);
};
useEffect(() => {
const fetchUnreadCount = async () => {
try {
const unreadCount =
await notificationService.getUnreadCount();
setUnreadCount(unreadCount);
} catch (error) {
console.error(
"Failed to fetch unread notification count:",
error
);
setUnreadCount(0);
}
};
fetchUnreadCount();
}, []);
const handleLogout = async () => {
await authService.logout();
navigate("/login", { replace: true });
};
const handleNotificationClick = () => {
navigate("/notifications");
};
const handleProfileClick = () => {
navigate("/admin/settings");
};
const getUserInitials = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
@ -339,6 +381,7 @@ export function AppShell() {
return "AD";
};
const getUserDisplayName = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName} ${user.lastName}`;
@ -346,6 +389,7 @@ export function AppShell() {
return user?.email || "Admin User";
};
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
@ -360,6 +404,7 @@ export function AppShell() {
</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
{filterNavItems(adminNavigationItems, user?.role).map((item) => (
@ -372,6 +417,7 @@ export function AppShell() {
))}
</nav>
{/* User Section */}
<div className="p-4 border-t">
<div className="flex items-center gap-3 mb-3">
@ -404,6 +450,7 @@ export function AppShell() {
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header */}
@ -418,7 +465,10 @@ export function AppShell() {
onClick={handleNotificationClick}
>
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
{/* ✅ FIXED: Show red badge only when unreadCount > 0 */}
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -458,6 +508,7 @@ export function AppShell() {
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto bg-background p-6">
<Outlet />
@ -465,4 +516,4 @@ export function AppShell() {
</div>
</div>
);
}
}

View File

@ -37,6 +37,7 @@ import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import type { Proforma, InvoiceItem } from "@/services/invoice.service";
export default function ProformaPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
@ -48,6 +49,7 @@ export default function ProformaPage() {
const [editingProforma, setEditingProforma] = useState<Proforma | null>(null);
const [proformaToDelete, setProformaToDelete] = useState<string | null>(null);
// Form State
const [formData, setFormData] = useState<Partial<Proforma>>({
proformaNumber: "",
@ -65,6 +67,7 @@ export default function ProformaPage() {
items: [] as InvoiceItem[],
});
const { data: proformaData, isLoading } = useQuery({
queryKey: ["admin", "proforma", page, search],
queryFn: () =>
@ -75,6 +78,7 @@ export default function ProformaPage() {
}),
});
const createMutation = useMutation({
mutationFn: (data: any) => invoiceService.createProforma(data),
onSuccess: () => {
@ -89,6 +93,7 @@ export default function ProformaPage() {
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
invoiceService.updateProforma(id, data),
@ -104,6 +109,7 @@ export default function ProformaPage() {
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => invoiceService.deleteProforma(id),
onSuccess: () => {
@ -116,6 +122,7 @@ export default function ProformaPage() {
},
});
const handleOpenCreate = () => {
setEditingProforma(null);
setFormData({
@ -136,16 +143,26 @@ export default function ProformaPage() {
setIsModalOpen(true);
};
// ✅ FIXED: Convert string values to numbers for items
const handleOpenEdit = (item: Proforma) => {
setEditingProforma(item);
setFormData({
...item,
issueDate: new Date(item.issueDate).toISOString().split("T")[0],
dueDate: new Date(item.dueDate).toISOString().split("T")[0],
// Convert all item string values to numbers
items: item.items.map(i => ({
...i,
quantity: Number(i.quantity),
unitPrice: Number(i.unitPrice),
total: Number(i.total),
})),
});
setIsModalOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingProforma) {
@ -155,18 +172,21 @@ export default function ProformaPage() {
}
};
// ✅ FIXED: Convert item.total to Number when calculating subtotal
const calculateTotals = (
items: InvoiceItem[],
tax: number,
discount: number,
) => {
const subtotal = items.reduce(
(acc: number, item: InvoiceItem) => acc + item.total,
(acc: number, item: InvoiceItem) => acc + Number(item.total),
0,
);
return subtotal + tax - discount;
};
const handleAddItem = () => {
const newItem: InvoiceItem = {
id: Math.random().toString(36).substring(7),
@ -181,6 +201,7 @@ export default function ProformaPage() {
});
};
const handleUpdateItem = (
index: number,
field: keyof InvoiceItem,
@ -189,11 +210,13 @@ export default function ProformaPage() {
const newItems = [...(formData.items || [])];
newItems[index] = { ...newItems[index], [field]: value } as InvoiceItem;
if (field === "quantity" || field === "unitPrice") {
newItems[index].total =
Number(newItems[index].quantity) * Number(newItems[index].unitPrice);
}
const newAmount = calculateTotals(
newItems,
formData.taxAmount || 0,
@ -202,6 +225,7 @@ export default function ProformaPage() {
setFormData({ ...formData, items: newItems, amount: newAmount });
};
const handleRemoveItem = (index: number) => {
const newItems = (formData.items || []).filter(
(_, i: number) => i !== index,
@ -214,6 +238,7 @@ export default function ProformaPage() {
setFormData({ ...formData, items: newItems, amount: newAmount });
};
const formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
@ -222,6 +247,7 @@ export default function ProformaPage() {
}).format(val);
};
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
@ -246,6 +272,7 @@ export default function ProformaPage() {
)}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
@ -323,7 +350,7 @@ export default function ProformaPage() {
</div>
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatCurrency(item.amount)}
{formatCurrency(Number(item.amount))}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(item.issueDate).toLocaleDateString()}
@ -405,6 +432,7 @@ export default function ProformaPage() {
)}
</Card>
{/* Create/Edit Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
@ -418,6 +446,7 @@ export default function ProformaPage() {
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
<div className="space-y-4">
<div className="grid gap-2">
@ -485,6 +514,7 @@ export default function ProformaPage() {
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
@ -575,6 +605,7 @@ export default function ProformaPage() {
</div>
</div>
{/* Line Items */}
<div className="border-t pt-6 mt-2">
<div className="flex items-center justify-between mb-4">
@ -592,6 +623,7 @@ export default function ProformaPage() {
</Button>
</div>
<div className="space-y-4">
{formData.items?.map((item: InvoiceItem, idx: number) => (
<div
@ -666,6 +698,7 @@ export default function ProformaPage() {
</div>
</div>
<DialogFooter className="border-t pt-6 mt-6">
<div className="flex items-center justify-between w-full">
<div className="text-right">
@ -707,6 +740,7 @@ export default function ProformaPage() {
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent className="rounded-none">
@ -748,4 +782,4 @@ export default function ProformaPage() {
</Dialog>
</div>
);
}
}

View File

@ -52,7 +52,7 @@ export default function NotificationBroadcastPage() {
},
onError: () =>
toast.error(
"Could not send. Ensure POST /admin/notifications/broadcast exists.",
"Could not send. Ensure POST /notifications/broadcast exists.",
),
})

View File

@ -1,8 +1,13 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
@ -11,95 +16,542 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Edit, Loader2 } from "lucide-react";
import { Edit, Loader2, Search, UserCheck, ChevronLeft, ChevronRight } from "lucide-react";
import { subscriptionService } from "@/services";
import { toast } from "sonner";
import type { ChapaTransactionStatus, BillingInterval } from "@/types/subscription.types";
import type { ApiError } from "@/types/error.types";
export default function SubscriptionsAdminPage() {
// ─── Status badge helper ──────────────────────────────────────────────────────
function TxStatusBadge({ status }: { status: ChapaTransactionStatus }) {
const map: Record<ChapaTransactionStatus, string> = {
SUCCESS: "bg-green-500",
PENDING: "bg-yellow-500",
FAILED: "bg-red-500",
CANCELLED: "bg-gray-500",
};
return <Badge className={map[status] ?? "bg-gray-500"}>{status}</Badge>;
}
function SubStatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
ACTIVE: "bg-green-500",
TRIALING: "bg-blue-500",
PAST_DUE: "bg-yellow-500",
CANCELLED: "bg-gray-500",
EXPIRED: "bg-red-500",
};
return <Badge className={map[status] ?? "bg-gray-500"}>{status}</Badge>;
}
// ─── Plans tab ────────────────────────────────────────────────────────────────
function PlansTab() {
const navigate = useNavigate();
const { data: plans, isLoading: plansLoading } = useQuery({
const { data: plans, isLoading } = useQuery({
queryKey: ["admin", "subscription-plans"],
queryFn: () => subscriptionService.getAdminPlans(),
});
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold">Subscription Plans</h2>
<p className="text-muted-foreground mt-1">
Manage plan pricing, feature flags, limits, and activation status.
Payment history is under{" "}
<button
type="button"
className="text-primary underline-offset-4 hover:underline"
onClick={() => navigate("/admin/transactions/subscriptions")}
>
Subscriptions Transactions
</button>
.
</p>
<Card>
<CardHeader>
<CardTitle>All Plans</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Loading plans...
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Plan</TableHead>
<TableHead>Monthly (ETB)</TableHead>
<TableHead>Yearly (ETB)</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plans?.map((plan) => (
<TableRow key={plan.id}>
<TableCell>
<div>
<p className="font-medium">{plan.displayName}</p>
<p className="text-xs text-muted-foreground">{plan.name}</p>
</div>
</TableCell>
<TableCell>
{plan.isFree ? "Free" : plan.monthlyPrice.toLocaleString()}
</TableCell>
<TableCell>
{plan.isFree ? "Free" : plan.yearlyPrice.toLocaleString()}
</TableCell>
<TableCell>
<Badge className={plan.isActive ? "bg-green-500" : "bg-gray-500"}>
{plan.isActive ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/subscriptions/plans/${plan.id}`)}
>
<Edit className="w-4 h-4 mr-2" />
Manage
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
// ─── All Subscriptions / Transactions tab ────────────────────────────────────
function AllSubscriptionsTab() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<ChapaTransactionStatus | "ALL">("ALL");
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ["admin", "subscription-transactions", page, statusFilter],
queryFn: () =>
subscriptionService.getSubscriptionTransactions({
page,
limit,
status: statusFilter === "ALL" ? undefined : statusFilter,
}),
});
const transactions = data?.data ?? [];
const meta = data?.meta;
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex items-center gap-3">
<Select
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v as ChapaTransactionStatus | "ALL");
setPage(1);
}}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="SUCCESS">Success</SelectItem>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="FAILED">Failed</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardHeader>
<CardTitle>All Plans</CardTitle>
<CardTitle>Subscription Transactions</CardTitle>
</CardHeader>
<CardContent>
{plansLoading ? (
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Loading plans...
Loading transactions...
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Plan</TableHead>
<TableHead>Monthly (ETB)</TableHead>
<TableHead>Yearly (ETB)</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plans?.map((plan) => (
<TableRow key={plan.id}>
<TableCell>
<div>
<p className="font-medium">{plan.displayName}</p>
<p className="text-xs text-muted-foreground">{plan.name}</p>
</div>
</TableCell>
<TableCell>
{plan.isFree ? "Free" : plan.monthlyPrice.toLocaleString()}
</TableCell>
<TableCell>
{plan.isFree ? "Free" : plan.yearlyPrice.toLocaleString()}
</TableCell>
<TableCell>
<Badge className={plan.isActive ? "bg-green-500" : "bg-gray-500"}>
{plan.isActive ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/admin/subscriptions/plans/${plan.id}`)
}
>
<Edit className="w-4 h-4 mr-2" />
Manage
</Button>
</TableCell>
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Ref</TableHead>
<TableHead>User</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{transactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-8 text-muted-foreground"
>
No transactions found.
</TableCell>
</TableRow>
) : (
transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell className="font-mono text-xs">
{tx.txRef}
</TableCell>
<TableCell>
<div>
<p className="text-sm font-medium">
{tx.user.firstName || tx.user.lastName
? `${tx.user.firstName ?? ""} ${
tx.user.lastName ?? ""
}`.trim()
: "—"}
</p>
<p className="text-xs text-muted-foreground">
{tx.user.email}
</p>
</div>
</TableCell>
<TableCell>
{tx.plan?.displayName ?? "—"}
</TableCell>
<TableCell>
{Number(tx.totalAmount).toLocaleString()}{" "}
{tx.currency}
</TableCell>
<TableCell>
{tx.billingInterval ?? "—"}
</TableCell>
<TableCell>
<TxStatusBadge status={tx.status} />
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(tx.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{meta && meta.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
<span>
Page {meta.page} of {meta.totalPages} · {meta.total} total
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!meta.hasPreviousPage}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}
// ─── User Lookup tab ──────────────────────────────────────────────────────────
function UserLookupTab() {
const queryClient = useQueryClient();
const [userId, setUserId] = useState("");
const [searchedId, setSearchedId] = useState("");
const [assignUserId, setAssignUserId] = useState("");
const [assignPlanId, setAssignPlanId] = useState("");
const [assignInterval, setAssignInterval] = useState<BillingInterval>("MONTHLY");
const { data: plans } = useQuery({
queryKey: ["admin", "subscription-plans"],
queryFn: () => subscriptionService.getAdminPlans(),
});
const {
data: sub,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["admin", "user-subscription", searchedId],
queryFn: () => subscriptionService.getUserSubscription(searchedId),
enabled: !!searchedId,
retry: false,
});
const assignMutation = useMutation({
mutationFn: () =>
subscriptionService.assignPlan(assignUserId, {
planId: assignPlanId,
billingInterval: assignInterval,
}),
onSuccess: () => {
toast.success("Plan assigned successfully");
queryClient.invalidateQueries({
queryKey: ["admin", "user-subscription", assignUserId],
});
// If the looked-up user is the same, refresh
if (searchedId === assignUserId) refetch();
setAssignUserId("");
setAssignPlanId("");
},
onError: (err) => {
const apiError = err as ApiError;
toast.error(apiError.response?.data?.message || "Failed to assign plan");
},
});
const handleSearch = () => {
const trimmed = userId.trim();
if (!trimmed) return;
setSearchedId(trimmed);
};
return (
<div className="space-y-6">
{/* Lookup */}
<Card>
<CardHeader>
<CardTitle>Per-User Subscription Lookup</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2 max-w-lg">
<Input
placeholder="Enter user ID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button onClick={handleSearch} disabled={!userId.trim()}>
<Search className="w-4 h-4 mr-2" />
Lookup
</Button>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-4">
<Loader2 className="w-4 h-4 animate-spin" />
Loading subscription...
</div>
)}
{error && (
<p className="text-sm text-destructive py-4">
No subscription found for this user, or the user does not exist.
</p>
)}
{sub && (
<div className="rounded-lg border p-4 space-y-4">
{/* Plan */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Plan</p>
<p className="text-lg font-semibold">{sub.plan.displayName}</p>
<p className="text-xs text-muted-foreground">{sub.plan.name}</p>
</div>
<SubStatusBadge status={sub.subscription.status} />
</div>
{/* Subscription details */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Billing</p>
<p className="font-medium">{sub.subscription.billingInterval}</p>
</div>
<div>
<p className="text-muted-foreground">Period Start</p>
<p className="font-medium">
{new Date(sub.subscription.currentPeriodStart).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-muted-foreground">Period End</p>
<p className="font-medium">
{new Date(sub.subscription.currentPeriodEnd).toLocaleDateString()}
</p>
</div>
{sub.subscription.nextBillingAt && (
<div>
<p className="text-muted-foreground">Next Billing</p>
<p className="font-medium">
{new Date(sub.subscription.nextBillingAt).toLocaleDateString()}
</p>
</div>
)}
{sub.subscription.trialEndsAt && (
<div>
<p className="text-muted-foreground">Trial Ends</p>
<p className="font-medium">
{new Date(sub.subscription.trialEndsAt).toLocaleDateString()}
</p>
</div>
)}
</div>
{/* Features */}
{Object.keys(sub.features).length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Features</p>
<div className="flex flex-wrap gap-2">
{Object.entries(sub.features).map(([key, enabled]) => (
<Badge
key={key}
variant={enabled ? "default" : "secondary"}
className="text-xs"
>
{key.replace(/_/g, " ")}
{enabled ? " ✓" : " ✗"}
</Badge>
))}
</div>
</div>
)}
{/* Usage */}
{Object.keys(sub.usage).length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Usage</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Object.entries(sub.usage).map(([key, { used, limit }]) => (
<div key={key} className="rounded border p-2 text-sm">
<p className="text-muted-foreground text-xs mb-1">
{key.replace(/_/g, " ")}
</p>
<p className="font-medium">
{used} / {limit === null ? "∞" : limit}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Assign Plan */}
<Card>
<CardHeader>
<CardTitle>Assign Plan to User</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 max-w-lg">
<div className="space-y-1.5">
<Label htmlFor="assign-user-id">User ID</Label>
<Input
id="assign-user-id"
placeholder="Enter user ID"
value={assignUserId}
onChange={(e) => setAssignUserId(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="assign-plan">Plan</Label>
<Select value={assignPlanId} onValueChange={setAssignPlanId}>
<SelectTrigger id="assign-plan">
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans
?.filter((p) => p.isActive)
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="assign-interval">Billing Interval</Label>
<Select
value={assignInterval}
onValueChange={(v) => setAssignInterval(v as BillingInterval)}
>
<SelectTrigger id="assign-interval">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MONTHLY">Monthly</SelectItem>
<SelectItem value="YEARLY">Yearly</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => assignMutation.mutate()}
disabled={!assignUserId.trim() || !assignPlanId || assignMutation.isPending}
>
{assignMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<UserCheck className="w-4 h-4 mr-2" />
)}
Assign Plan
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// ─── Root page ────────────────────────────────────────────────────────────────
export default function SubscriptionsAdminPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold">Subscriptions</h2>
<p className="text-muted-foreground mt-1">
Manage plans, view all subscription transactions, and look up or assign plans for
individual users.
</p>
</div>
<Tabs defaultValue="plans">
<TabsList>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="all">All Subscriptions</TabsTrigger>
<TabsTrigger value="user">User Lookup</TabsTrigger>
</TabsList>
<TabsContent value="plans" className="mt-4">
<PlansTab />
</TabsContent>
<TabsContent value="all" className="mt-4">
<AllSubscriptionsTab />
</TabsContent>
<TabsContent value="user" className="mt-4">
<UserLookupTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -183,10 +183,7 @@ export default function SubscriptionTransactionsPage() {
</div>
<div className="flex flex-col">
<span className="text-sm font-black text-slate-900 tracking-tight">
{row.userEmail}
</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{row.userId}
{row.user.email}
</span>
</div>
</div>
@ -197,26 +194,21 @@ export default function SubscriptionTransactionsPage() {
variant="secondary"
className="bg-slate-100 text-slate-700 hover:bg-slate-200 border-none rounded-lg px-2.5 py-0.5 text-[10px] font-black uppercase"
>
{row.planName}
{row.plan?.name}
</Badge>
</div>
</td>
<td className="px-8 py-6">
<span className="text-sm font-black text-slate-900 underline decoration-primary/20 underline-offset-4">
{formatMoney(row.amount, row.currency)}
{formatMoney(Number(row.totalAmount), row.currency)}
</span>
</td>
<td className="px-8 py-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 font-bold text-[11px] text-slate-700">
<CreditCard className="w-3.5 h-3.5 text-slate-400" />
{row.provider}
{ "Default"}
</div>
{row.providerRef && (
<span className="text-[10px] font-mono text-slate-400 bg-slate-100/50 px-1.5 py-0.5 rounded border border-slate-200/50 w-fit">
{row.providerRef}
</span>
)}
</div>
</td>
<td className="px-8 py-6">
@ -227,13 +219,7 @@ export default function SubscriptionTransactionsPage() {
</span>
</span>
</td>
{tab === "failed" && (
<td className="px-8 py-6 max-w-[240px]">
<p className="text-xs font-bold text-rose-600 bg-rose-50/50 p-2 rounded-lg border border-rose-100/50">
{row.failureReason ?? "Unknown Error Logic"}
</p>
</td>
)}
<td className="px-8 py-6 text-right">
<Badge
className={cn(
@ -275,34 +261,45 @@ export default function SubscriptionTransactionsPage() {
</div>
{/* Pagination Controls */}
{data && data.totalPages > 1 && (
{data?.meta && data.meta.totalPages > 1 && (
<div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Showing{" "}
<span className="text-slate-900">{data.data.length}</span> of{" "}
<span className="text-slate-900">{data.total}</span> entries
<span className="text-slate-900">
{data.data.length}
</span>{" "}
of{" "}
<span className="text-slate-900">
{data.meta.total}
</span>{" "}
entries
</p>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
disabled={page <= 1}
disabled={!data.meta.hasPreviousPage}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-4 h-4 mr-1" /> Prev
<ChevronLeft className="w-4 h-4 mr-1" />
Prev
</Button>
<div className="flex items-center px-4 text-xs font-black text-primary bg-white rounded-xl shadow-sm border border-slate-200/50">
{data.page} / {data.totalPages}
{data.meta.page} / {data.meta.totalPages}
</div>
<Button
variant="ghost"
size="sm"
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
disabled={page >= data.totalPages}
disabled={!data.meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
Next <ChevronRight className="w-4 h-4 ml-1" />
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>

View File

@ -82,7 +82,7 @@ export default function LoginPage() {
<Input
id="email"
type="email"
placeholder="admin@example.com"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required

View File

@ -62,25 +62,32 @@ apiClient.interceptors.response.use(
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
// No refresh token available — clear session and redirect
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
window.location.href = "/login";
return Promise.reject(error);
}
try {
// Try to refresh token
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
{ withCredentials: true },
);
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
{ withCredentials: true },
);
const { accessToken } = response.data;
localStorage.setItem("access_token", accessToken);
const { accessToken } = response.data;
localStorage.setItem("access_token", accessToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
}
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed - logout user
// Refresh failed — clear session and redirect
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");

View File

@ -29,7 +29,7 @@ class FaqService {
audience?: FaqAudience
search?: string
}): Promise<PaginatedFaqs> {
const response = await apiClient.get<PaginatedFaqs>("/admin/faq", {
const response = await apiClient.get<PaginatedFaqs>("/faq", {
params,
})
return response.data
@ -42,7 +42,7 @@ class FaqService {
sortOrder?: number
isPublished?: boolean
}): Promise<FaqEntry> {
const response = await apiClient.post<FaqEntry>("/admin/faq", data)
const response = await apiClient.post<FaqEntry>("/faq", data)
return response.data
}
@ -55,12 +55,12 @@ class FaqService {
>
>,
): Promise<FaqEntry> {
const response = await apiClient.patch<FaqEntry>(`/admin/faq/${id}`, data)
const response = await apiClient.patch<FaqEntry>(`/faq/${id}`, data)
return response.data
}
async remove(id: string): Promise<void> {
await apiClient.delete(`/admin/faq/${id}`)
await apiClient.delete(`/faq/${id}`)
}
}

View File

@ -33,7 +33,7 @@ export interface PaginatedIssues {
class IssueService {
async list(filters: IssueFilters = {}): Promise<PaginatedIssues> {
const response = await apiClient.get<PaginatedIssues>("/admin/issues", {
const response = await apiClient.get<PaginatedIssues>("/issues", {
params: filters,
})
return response.data
@ -44,7 +44,7 @@ class IssueService {
description: string
priority?: SupportIssue["priority"]
}): Promise<SupportIssue> {
const response = await apiClient.post<SupportIssue>("/admin/issues", data)
const response = await apiClient.post<SupportIssue>("/issues", data)
return response.data
}
@ -53,7 +53,7 @@ class IssueService {
status: IssueStatus,
): Promise<SupportIssue> {
const response = await apiClient.patch<SupportIssue>(
`/admin/issues/${id}`,
`/issues/${id}`,
{ status },
)
return response.data

View File

@ -52,7 +52,10 @@ export interface SendBroadcastRequest {
audience: "all_end_users" | "system_users_only" | "everyone_with_access";
channels: ("push" | "sms" | "email")[];
}
export interface NotificationListResponse {
data: Notification[];
total: number;
}
class NotificationService {
/**
* Get all notifications for current user (Paginated)
@ -64,20 +67,20 @@ class NotificationService {
status?: string;
search?: string;
}): Promise<Notification[]> {
const response = await apiClient.get<Notification[]>("/notifications", {
const response = await apiClient.get<NotificationListResponse>("/notifications", {
params,
});
return response.data;
return response.data.data;
}
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const response = await apiClient.get<{ count: number }>(
const response = await apiClient.get<{ unreadCount: number }>(
"/notifications/unread-count",
);
return response.data.count;
return response.data.unreadCount;
}
/**
@ -91,7 +94,7 @@ class NotificationService {
* Mark all notifications as read
*/
async markAllAsRead(): Promise<void> {
await apiClient.post("/notifications/read-all");
await apiClient.put("/notifications/mark-all-read");
}
/**
@ -101,7 +104,7 @@ class NotificationService {
data: SendBroadcastRequest,
): Promise<{ success: boolean }> {
const response = await apiClient.post<{ success: boolean }>(
"/admin/notifications/broadcast",
"/notifications/broadcast",
data,
);
return response.data;

View File

@ -3,34 +3,64 @@ import apiClient from "./api/client"
export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING"
export interface SubscriptionTransaction {
id: string
userId: string
userEmail: string
planName: string
amount: number
currency: string
status: SubscriptionPaymentStatus
provider: string
providerRef?: string
failureReason?: string
createdAt: string
id: string;
txRef: string;
checkoutUrl: string;
totalAmount: string;
currency: string;
status: SubscriptionPaymentStatus;
purpose: string;
chapaTransactionId: string | null;
chapaData: Record<string, any> | null;
returnUrl: string;
userId: string;
invoiceId: string;
planId: string | null;
billingInterval: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
user: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
};
plan: {
id: string;
name: string;
displayName: string;
} | null;
}
export interface SubscriptionTransactionFilters {
page?: number
limit?: number
status?: SubscriptionPaymentStatus
search?: string
page?: number;
limit?: number;
status?: SubscriptionPaymentStatus;
search?: string;
}
export interface PaginatedSubscriptionTx {
data: SubscriptionTransaction[]
total: number
page: number
limit: number
totalPages: number
}
data: SubscriptionTransaction[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
class SubscriptionTransactionService {
async getTransactions(
filters: SubscriptionTransactionFilters = {},