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
Showing only changes of commit a9b7df64fa - Show all commits

View File

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

View File

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

View File

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

View File

@ -52,7 +52,7 @@ export default function NotificationBroadcastPage() {
}, },
onError: () => onError: () =>
toast.error( 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 { 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; 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 { import {
Table, Table,
TableBody, TableBody,
@ -11,41 +16,49 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } 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 { 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 navigate = useNavigate();
const { data: plans, isLoading } = useQuery({
const { data: plans, isLoading: plansLoading } = useQuery({
queryKey: ["admin", "subscription-plans"], queryKey: ["admin", "subscription-plans"],
queryFn: () => subscriptionService.getAdminPlans(), queryFn: () => subscriptionService.getAdminPlans(),
}); });
return ( 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>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>All Plans</CardTitle> <CardTitle>All Plans</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{plansLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" /> <Loader2 className="w-5 h-5 mr-2 animate-spin" />
Loading plans... Loading plans...
@ -85,9 +98,7 @@ export default function SubscriptionsAdminPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => navigate(`/admin/subscriptions/plans/${plan.id}`)}
navigate(`/admin/subscriptions/plans/${plan.id}`)
}
> >
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-4 h-4 mr-2" />
Manage Manage
@ -100,6 +111,447 @@ export default function SubscriptionsAdminPage() {
)} )}
</CardContent> </CardContent>
</Card> </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>Subscription Transactions</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 transactions...
</div>
) : (
<>
<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>
</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> </div>
); );
} }

View File

@ -183,10 +183,7 @@ export default function SubscriptionTransactionsPage() {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-black text-slate-900 tracking-tight"> <span className="text-sm font-black text-slate-900 tracking-tight">
{row.userEmail} {row.user.email}
</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{row.userId}
</span> </span>
</div> </div>
</div> </div>
@ -197,26 +194,21 @@ export default function SubscriptionTransactionsPage() {
variant="secondary" 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" 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> </Badge>
</div> </div>
</td> </td>
<td className="px-8 py-6"> <td className="px-8 py-6">
<span className="text-sm font-black text-slate-900 underline decoration-primary/20 underline-offset-4"> <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> </span>
</td> </td>
<td className="px-8 py-6"> <td className="px-8 py-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 font-bold text-[11px] text-slate-700"> <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" /> <CreditCard className="w-3.5 h-3.5 text-slate-400" />
{row.provider} { "Default"}
</div> </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> </div>
</td> </td>
<td className="px-8 py-6"> <td className="px-8 py-6">
@ -227,13 +219,7 @@ export default function SubscriptionTransactionsPage() {
</span> </span>
</span> </span>
</td> </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"> <td className="px-8 py-6 text-right">
<Badge <Badge
className={cn( className={cn(
@ -275,34 +261,45 @@ export default function SubscriptionTransactionsPage() {
</div> </div>
{/* Pagination Controls */} {/* 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"> <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"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Showing{" "} Showing{" "}
<span className="text-slate-900">{data.data.length}</span> of{" "} <span className="text-slate-900">
<span className="text-slate-900">{data.total}</span> entries {data.data.length}
</span>{" "}
of{" "}
<span className="text-slate-900">
{data.meta.total}
</span>{" "}
entries
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white" 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))} 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> </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"> <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> </div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white" 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)} onClick={() => setPage((p) => p + 1)}
> >
Next <ChevronRight className="w-4 h-4 ml-1" /> Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button> </Button>
</div> </div>
</div> </div>

View File

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

View File

@ -62,10 +62,18 @@ apiClient.interceptors.response.use(
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
try {
// Try to refresh token
const refreshToken = localStorage.getItem("refresh_token"); const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
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 {
const response = await axios.post( const response = await axios.post(
`${API_BASE_URL}/auth/refresh`, `${API_BASE_URL}/auth/refresh`,
{ refreshToken }, { refreshToken },
@ -78,9 +86,8 @@ apiClient.interceptors.response.use(
// Retry original request with new token // Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`; originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest); return apiClient(originalRequest);
}
} catch (refreshError) { } catch (refreshError) {
// Refresh failed - logout user // Refresh failed — clear session and redirect
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token"); localStorage.removeItem("refresh_token");
localStorage.removeItem("user"); localStorage.removeItem("user");

View File

@ -29,7 +29,7 @@ class FaqService {
audience?: FaqAudience audience?: FaqAudience
search?: string search?: string
}): Promise<PaginatedFaqs> { }): Promise<PaginatedFaqs> {
const response = await apiClient.get<PaginatedFaqs>("/admin/faq", { const response = await apiClient.get<PaginatedFaqs>("/faq", {
params, params,
}) })
return response.data return response.data
@ -42,7 +42,7 @@ class FaqService {
sortOrder?: number sortOrder?: number
isPublished?: boolean isPublished?: boolean
}): Promise<FaqEntry> { }): Promise<FaqEntry> {
const response = await apiClient.post<FaqEntry>("/admin/faq", data) const response = await apiClient.post<FaqEntry>("/faq", data)
return response.data return response.data
} }
@ -55,12 +55,12 @@ class FaqService {
> >
>, >,
): Promise<FaqEntry> { ): Promise<FaqEntry> {
const response = await apiClient.patch<FaqEntry>(`/admin/faq/${id}`, data) const response = await apiClient.patch<FaqEntry>(`/faq/${id}`, data)
return response.data return response.data
} }
async remove(id: string): Promise<void> { 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 { class IssueService {
async list(filters: IssueFilters = {}): Promise<PaginatedIssues> { async list(filters: IssueFilters = {}): Promise<PaginatedIssues> {
const response = await apiClient.get<PaginatedIssues>("/admin/issues", { const response = await apiClient.get<PaginatedIssues>("/issues", {
params: filters, params: filters,
}) })
return response.data return response.data
@ -44,7 +44,7 @@ class IssueService {
description: string description: string
priority?: SupportIssue["priority"] priority?: SupportIssue["priority"]
}): Promise<SupportIssue> { }): Promise<SupportIssue> {
const response = await apiClient.post<SupportIssue>("/admin/issues", data) const response = await apiClient.post<SupportIssue>("/issues", data)
return response.data return response.data
} }
@ -53,7 +53,7 @@ class IssueService {
status: IssueStatus, status: IssueStatus,
): Promise<SupportIssue> { ): Promise<SupportIssue> {
const response = await apiClient.patch<SupportIssue>( const response = await apiClient.patch<SupportIssue>(
`/admin/issues/${id}`, `/issues/${id}`,
{ status }, { status },
) )
return response.data return response.data

View File

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

View File

@ -3,34 +3,64 @@ import apiClient from "./api/client"
export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING" export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING"
export interface SubscriptionTransaction { export interface SubscriptionTransaction {
id: string id: string;
userId: string txRef: string;
userEmail: string checkoutUrl: string;
planName: string
amount: number totalAmount: string;
currency: string currency: string;
status: SubscriptionPaymentStatus
provider: string status: SubscriptionPaymentStatus;
providerRef?: string purpose: string;
failureReason?: string
createdAt: 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 { export interface SubscriptionTransactionFilters {
page?: number page?: number;
limit?: number limit?: number;
status?: SubscriptionPaymentStatus status?: SubscriptionPaymentStatus;
search?: string search?: string;
} }
export interface PaginatedSubscriptionTx { export interface PaginatedSubscriptionTx {
data: SubscriptionTransaction[] data: SubscriptionTransaction[];
total: number
page: number
limit: number
totalPages: number
}
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
class SubscriptionTransactionService { class SubscriptionTransactionService {
async getTransactions( async getTransactions(
filters: SubscriptionTransactionFilters = {}, filters: SubscriptionTransactionFilters = {},