admin updates #3
|
|
@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
<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>
|
</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 />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,95 +16,542 @@ 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">
|
<Card>
|
||||||
<div>
|
<CardHeader>
|
||||||
<h2 className="text-3xl font-bold">Subscription Plans</h2>
|
<CardTitle>All Plans</CardTitle>
|
||||||
<p className="text-muted-foreground mt-1">
|
</CardHeader>
|
||||||
Manage plan pricing, feature flags, limits, and activation status.
|
<CardContent>
|
||||||
Payment history is under{" "}
|
{isLoading ? (
|
||||||
<button
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
type="button"
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
Loading plans...
|
||||||
onClick={() => navigate("/admin/transactions/subscriptions")}
|
</div>
|
||||||
>
|
) : (
|
||||||
Subscriptions → Transactions
|
<Table>
|
||||||
</button>
|
<TableHeader>
|
||||||
.
|
<TableRow>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>All Plans</CardTitle>
|
<CardTitle>Subscription Transactions</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 transactions...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<>
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>Plan</TableHead>
|
<TableRow>
|
||||||
<TableHead>Monthly (ETB)</TableHead>
|
<TableHead>Ref</TableHead>
|
||||||
<TableHead>Yearly (ETB)</TableHead>
|
<TableHead>User</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Plan</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead>Amount</TableHead>
|
||||||
</TableRow>
|
<TableHead>Interval</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableBody>
|
<TableHead>Date</TableHead>
|
||||||
{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>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
|
||||||
</Table>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -62,25 +62,32 @@ apiClient.interceptors.response.use(
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
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 {
|
||||||
// Try to refresh token
|
const response = await axios.post(
|
||||||
const refreshToken = localStorage.getItem("refresh_token");
|
`${API_BASE_URL}/auth/refresh`,
|
||||||
if (refreshToken) {
|
{ refreshToken },
|
||||||
const response = await axios.post(
|
{ withCredentials: true },
|
||||||
`${API_BASE_URL}/auth/refresh`,
|
);
|
||||||
{ refreshToken },
|
|
||||||
{ withCredentials: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { accessToken } = response.data;
|
const { accessToken } = response.data;
|
||||||
localStorage.setItem("access_token", accessToken);
|
localStorage.setItem("access_token", accessToken);
|
||||||
|
|
||||||
// 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");
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user