Merge pull request 'admin updates' (#3) from el-ui into prod
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 1m19s
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 1m19s
Reviewed-on: #3
This commit is contained in:
commit
7966a8163d
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user