Add staff roles, subscription txns, system users, issues, FAQ, broadcast

- Introduce SUPER_ADMIN, ADMIN, CUSTOMER_SUPPORT with admin-roles helpers and useAdminRole hook
- New pages: subscription transactions, system members, issues, FAQ & support, notification broadcast
- Services and API paths for admin subscription-transactions, system-members, issues, faq, broadcast
- Nav and quick search filtered by role; login accepts all panel roles

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-04-15 10:45:10 +03:00
parent 4795822065
commit 23ab82a726
19 changed files with 1867 additions and 26 deletions

View File

@ -30,6 +30,11 @@ import PaymentRequestsPage from "@/pages/admin/payments/payment-requests";
import InvoicesPage from "@/pages/admin/invoices/invoices-list"; import InvoicesPage from "@/pages/admin/invoices/invoices-list";
import ProformaPage from "@/pages/admin/invoices/proforma-list"; import ProformaPage from "@/pages/admin/invoices/proforma-list";
import ProformaRequestsPage from "@/pages/admin/invoices/proforma-requests"; import ProformaRequestsPage from "@/pages/admin/invoices/proforma-requests";
import SubscriptionTransactionsPage from "@/pages/admin/transactions/subscription-transactions";
import SystemMembersPage from "@/pages/admin/system-members";
import IssuesPage from "@/pages/admin/issues";
import FaqSupportPage from "@/pages/admin/support/faq";
import NotificationBroadcastPage from "@/pages/admin/notifications/broadcast";
function App() { function App() {
return ( return (
@ -87,6 +92,17 @@ function App() {
path="admin/payment-requests" path="admin/payment-requests"
element={<PaymentRequestsPage />} element={<PaymentRequestsPage />}
/> />
<Route
path="admin/transactions/subscriptions"
element={<SubscriptionTransactionsPage />}
/>
<Route path="admin/system-members" element={<SystemMembersPage />} />
<Route path="admin/issues" element={<IssuesPage />} />
<Route path="admin/support/faq" element={<FaqSupportPage />} />
<Route
path="admin/notifications/broadcast"
element={<NotificationBroadcastPage />}
/>
<Route path="admin/invoices" element={<InvoicesPage />} /> <Route path="admin/invoices" element={<InvoicesPage />} />
<Route path="admin/proforma" element={<ProformaPage />} /> <Route path="admin/proforma" element={<ProformaPage />} />
<Route <Route

View File

@ -16,10 +16,12 @@ import {
ADMIN_SEARCH_ROUTES, ADMIN_SEARCH_ROUTES,
groupAdminSearchRoutes, groupAdminSearchRoutes,
} from "@/config/admin-search-routes"; } from "@/config/admin-search-routes";
import { useAdminRole } from "@/hooks/use-admin-role";
export function AdminQuickSearch() { export function AdminQuickSearch() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { canAccessSystemMembers, canSendBroadcast } = useAdminRole();
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@ -32,7 +34,15 @@ export function AdminQuickSearch() {
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, []); }, []);
const grouped = groupAdminSearchRoutes(ADMIN_SEARCH_ROUTES); const searchRoutes = ADMIN_SEARCH_ROUTES.filter((r) => {
if (r.path === "/admin/system-members" && !canAccessSystemMembers)
return false;
if (r.path === "/admin/notifications/broadcast" && !canSendBroadcast)
return false;
return true;
});
const grouped = groupAdminSearchRoutes(searchRoutes);
const groups = [...grouped.keys()]; const groups = [...grouped.keys()];
return ( return (

View File

@ -21,6 +21,11 @@ import {
Gauge, Gauge,
DollarSign, DollarSign,
HardDrive, HardDrive,
ArrowRightLeft,
UserCog,
LifeBuoy,
HelpCircle,
Send,
} from "lucide-react" } from "lucide-react"
export interface AdminSearchRoute { export interface AdminSearchRoute {
@ -92,6 +97,15 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
group: "Commerce", group: "Commerce",
icon: FileClock, icon: FileClock,
}, },
{
id: "subscription-txns",
path: "/admin/transactions/subscriptions",
title: "Subscription transactions",
description:
"Successful and failed subscription charges for the platform.",
group: "Commerce",
icon: ArrowRightLeft,
},
{ {
id: "users", id: "users",
path: "/admin/users", path: "/admin/users",
@ -100,6 +114,15 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
group: "People & activity", group: "People & activity",
icon: Users, icon: Users,
}, },
{
id: "system-members",
path: "/admin/system-members",
title: "System users",
description:
"Internal panel accounts (system admin, admin, customer support).",
group: "People & activity",
icon: UserCog,
},
{ {
id: "logs", id: "logs",
path: "/admin/logs", path: "/admin/logs",
@ -116,6 +139,24 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
group: "Operations", group: "Operations",
icon: Settings, icon: Settings,
}, },
{
id: "issues",
path: "/admin/issues",
title: "Issues",
description:
"Support tickets reported by customers or internal system users.",
group: "Support",
icon: LifeBuoy,
},
{
id: "faq-support",
path: "/admin/support/faq",
title: "FAQ & support",
description:
"Published Q&A for end users and staff; editors manage entries.",
group: "Support",
icon: HelpCircle,
},
{ {
id: "maintenance", id: "maintenance",
path: "/admin/maintenance", path: "/admin/maintenance",
@ -244,13 +285,24 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
group: "Operations", group: "Operations",
icon: Heart, icon: Heart,
}, },
{
id: "broadcast",
path: "/admin/notifications/broadcast",
title: "Send notification",
description:
"Broadcast push, SMS, and email to selected audiences (admins).",
group: "Communications",
icon: Send,
},
] ]
const GROUP_ORDER = [ const GROUP_ORDER = [
"Overview", "Overview",
"Commerce", "Commerce",
"People & activity", "People & activity",
"Support",
"Operations", "Operations",
"Communications",
"Security", "Security",
"Analytics", "Analytics",
] ]

View File

@ -0,0 +1,29 @@
import { useMemo } from "react"
import { authService } from "@/services"
import {
canAccessSystemMembers,
canEdit,
canSendBroadcast,
hasPanelAccess,
isSuperAdmin,
roleLabel,
} from "@/lib/admin-roles"
export function useAdminRole() {
return useMemo(() => {
const user = authService.getCurrentUser() as
| { role?: string; email?: string }
| null
const role = user?.role
return {
role,
roleLabel: roleLabel(role),
isSuperAdmin: isSuperAdmin(role),
canEdit: canEdit(role),
canAccessSystemMembers: canAccessSystemMembers(role),
canSendBroadcast: canSendBroadcast(role),
hasPanelAccess: hasPanelAccess(role),
}
}, [])
}

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, type ComponentType } from "react";
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"; import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
@ -18,6 +18,11 @@ import {
Receipt, Receipt,
FileSearch, FileSearch,
ClipboardList, ClipboardList,
ArrowRightLeft,
UserCog,
LifeBuoy,
HelpCircle,
Send,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AdminQuickSearch } from "@/components/admin-quick-search"; import { AdminQuickSearch } from "@/components/admin-quick-search";
@ -31,6 +36,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { roleLabel } from "@/lib/admin-roles";
import { authService } from "@/services"; import { authService } from "@/services";
interface User { interface User {
@ -40,7 +46,15 @@ interface User {
role: string; role: string;
} }
const adminNavigationItems = [ type NavItem = {
icon: ComponentType<{ className?: string }>;
label: string;
path: string;
/** Omit = visible to all panel roles */
visible?: (role: string | undefined) => boolean;
};
const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" }, { icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" }, { icon: Receipt, label: "Invoices", path: "/admin/invoices" },
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" }, { icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
@ -55,8 +69,21 @@ const adminNavigationItems = [
label: "Payment Requests", label: "Payment Requests",
path: "/admin/payment-requests", path: "/admin/payment-requests",
}, },
{
icon: ArrowRightLeft,
label: "Subscription transactions",
path: "/admin/transactions/subscriptions",
},
{ icon: Users, label: "Users", path: "/admin/users" }, { icon: Users, label: "Users", path: "/admin/users" },
{
icon: UserCog,
label: "System users",
path: "/admin/system-members",
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN",
},
{ icon: FileText, label: "Logs", path: "/admin/logs" }, { icon: FileText, label: "Logs", path: "/admin/logs" },
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
{ icon: Settings, label: "Settings", path: "/admin/settings" }, { icon: Settings, label: "Settings", path: "/admin/settings" },
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" }, { icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" }, { icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
@ -64,6 +91,12 @@ const adminNavigationItems = [
{ icon: Shield, label: "Security", path: "/admin/security" }, { icon: Shield, label: "Security", path: "/admin/security" },
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" }, { icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
{ icon: Heart, label: "System Health", path: "/admin/health" }, { icon: Heart, label: "System Health", path: "/admin/health" },
{
icon: Send,
label: "Send notification",
path: "/admin/notifications/broadcast",
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN",
},
]; ];
export function AppShell() { export function AppShell() {
@ -136,24 +169,28 @@ export function AppShell() {
{/* 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">
{adminNavigationItems.map((item) => { {adminNavigationItems
const Icon = item.icon; .filter((item) =>
return ( item.visible ? item.visible(user?.role) : true,
<Link )
key={item.path} .map((item) => {
to={item.path} const Icon = item.icon;
className={cn( return (
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors", <Link
isActive(item.path) key={item.path}
? "bg-primary text-primary-foreground" to={item.path}
: "text-foreground/70 hover:bg-accent hover:text-foreground", className={cn(
)} "flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
> isActive(item.path)
<Icon className="w-5 h-5" /> ? "bg-primary text-primary-foreground"
{item.label} : "text-foreground/70 hover:bg-accent hover:text-foreground",
</Link> )}
); >
})} <Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav> </nav>
{/* User Section */} {/* User Section */}
@ -169,6 +206,11 @@ export function AppShell() {
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{user?.email || "admin@example.com"} {user?.email || "admin@example.com"}
</p> </p>
{user?.role && (
<p className="text-[10px] font-semibold text-primary/80 uppercase tracking-wider mt-0.5">
{roleLabel(user.role)}
</p>
)}
</div> </div>
</div> </div>
<Button <Button

55
src/lib/admin-roles.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* Panel roles for admin / support staff.
* Backend should return one of these on the authenticated user object.
*/
export const AdminRole = {
SUPER_ADMIN: "SUPER_ADMIN",
ADMIN: "ADMIN",
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
} as const
export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole]
const PANEL_ROLES = new Set<string>([
AdminRole.SUPER_ADMIN,
AdminRole.ADMIN,
AdminRole.CUSTOMER_SUPPORT,
])
export function hasPanelAccess(role: string | undefined): boolean {
if (!role) return false
return PANEL_ROLES.has(role)
}
/** Full control (all menus + destructive actions) */
export function isSuperAdmin(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN
}
/** Can create/edit content (not read-only support) */
export function canEdit(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Internal system users / members management */
export function canAccessSystemMembers(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Push / SMS / email broadcast composer */
export function canSendBroadcast(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
export function roleLabel(role: string | undefined): string {
switch (role) {
case AdminRole.SUPER_ADMIN:
return "System Admin"
case AdminRole.ADMIN:
return "Admin"
case AdminRole.CUSTOMER_SUPPORT:
return "Customer Support"
default:
return role ?? "User"
}
}

View File

@ -0,0 +1,316 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Search, Plus } from "lucide-react"
import { issueService } from "@/services"
import type { IssueStatus } from "@/services/issue.service"
import { useAdminRole } from "@/hooks/use-admin-role"
import { toast } from "sonner"
const badgeForStatus = (s: IssueStatus) => {
const map: Record<IssueStatus, string> = {
OPEN: "bg-orange-100 text-orange-900 border-orange-200",
IN_PROGRESS: "bg-blue-100 text-blue-900 border-blue-200",
RESOLVED: "bg-emerald-100 text-emerald-900 border-emerald-200",
CLOSED: "bg-gray-100 text-gray-700 border-gray-200",
}
return map[s] ?? ""
}
export default function IssuesPage() {
const { canEdit } = useAdminRole()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [open, setOpen] = useState(false)
const [newIssue, setNewIssue] = useState({
title: "",
description: "",
priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH",
})
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "issues", page, search],
queryFn: () =>
issueService.list({
page,
limit: 12,
search: search.trim() || undefined,
}),
})
const createMutation = useMutation({
mutationFn: () => issueService.create(newIssue),
onSuccess: () => {
toast.success("Issue reported")
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] })
setOpen(false)
setNewIssue({
title: "",
description: "",
priority: "MEDIUM",
})
},
onError: () => toast.error("Could not create issue"),
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: IssueStatus }) =>
issueService.updateStatus(id, status),
onSuccess: () => {
toast.success("Status updated")
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] })
},
onError: () => toast.error("Update failed"),
})
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-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Issues
</h1>
<p className="text-gray-500 mt-1 max-w-xl">
Customers and internal system users can report problems here. Support
staff with edit access can move tickets through the workflow.
</p>
</div>
<Button
className="rounded-none gap-2"
onClick={() => setOpen(true)}
>
<Plus className="h-4 w-4" />
Report issue
</Button>
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Queue
</CardTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search title or email…"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</div>
</CardHeader>
<CardContent className="p-0">
{error && (
<p className="p-6 text-sm text-amber-700 bg-amber-50 border-b">
Wire up <code className="text-xs">GET /admin/issues</code> on your API
to list tickets.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Title
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Reporter
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Type
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Priority
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Updated
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={6}
className="px-6 py-16 text-center text-gray-400 animate-pulse"
>
Loading
</td>
</tr>
) : data?.data?.length ? (
data.data.map((issue) => (
<tr key={issue.id} className="hover:bg-gray-50 align-top">
<td className="px-6 py-4 text-sm font-semibold text-gray-900 max-w-xs">
{issue.title}
<p className="text-[11px] text-gray-500 font-normal mt-1 line-clamp-2">
{issue.description}
</p>
</td>
<td className="px-6 py-4 text-xs text-gray-600">
<div>{issue.reporterEmail}</div>
<Badge
variant="outline"
className="mt-1 text-[10px] rounded-none"
>
{issue.reporterType}
</Badge>
</td>
<td className="px-6 py-4 text-xs text-gray-500"></td>
<td className="px-6 py-4 text-xs font-bold text-gray-700">
{issue.priority}
</td>
<td className="px-6 py-4 text-xs text-gray-600">
{issue.updatedAt
? new Date(issue.updatedAt).toLocaleString()
: new Date(issue.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 text-right">
{canEdit ? (
<Select
value={issue.status}
onValueChange={(v) =>
statusMutation.mutate({
id: issue.id,
status: v as IssueStatus,
})
}
>
<SelectTrigger className="h-8 w-[140px] rounded-none text-xs ml-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_PROGRESS">
In progress
</SelectItem>
<SelectItem value="RESOLVED">Resolved</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
) : (
<Badge
variant="outline"
className={`rounded-none text-[10px] ${badgeForStatus(issue.status)}`}
>
{issue.status}
</Badge>
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={6}
className="px-6 py-16 text-center text-gray-400 italic text-sm"
>
No issues loaded.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="rounded-none max-w-lg">
<DialogHeader>
<DialogTitle>Report an issue</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1">
<Label htmlFor="iss-title">Title</Label>
<Input
id="iss-title"
value={newIssue.title}
onChange={(e) =>
setNewIssue((n) => ({ ...n, title: e.target.value }))
}
className="rounded-none"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="iss-desc">Description</Label>
<textarea
id="iss-desc"
value={newIssue.description}
onChange={(e) =>
setNewIssue((n) => ({ ...n, description: e.target.value }))
}
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="grid gap-1">
<Label>Priority</Label>
<Select
value={newIssue.priority}
onValueChange={(v) =>
setNewIssue((n) => ({
...n,
priority: v as typeof newIssue.priority,
}))
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Low</SelectItem>
<SelectItem value="MEDIUM">Medium</SelectItem>
<SelectItem value="HIGH">High</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="rounded-none"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
className="rounded-none"
disabled={
createMutation.isPending ||
!newIssue.title.trim() ||
!newIssue.description.trim()
}
onClick={() => createMutation.mutate()}
>
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,214 @@
import { useState } from "react"
import { Navigate } from "react-router-dom"
import { useMutation } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Bell, Mail, MessageSquare, Send } from "lucide-react"
import { notificationService } from "@/services"
import { useAdminRole } from "@/hooks/use-admin-role"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
export default function NotificationBroadcastPage() {
const { canSendBroadcast } = useAdminRole()
const [title, setTitle] = useState("")
const [message, setMessage] = useState("")
const [audience, setAudience] = useState<
"all_end_users" | "system_users_only" | "everyone_with_access"
>("all_end_users")
const [channels, setChannels] = useState({
push: true,
sms: false,
email: true,
})
const mutation = useMutation({
mutationFn: () =>
notificationService.sendBroadcast({
title,
message,
audience,
channels: (
[
channels.push && "push",
channels.sms && "sms",
channels.email && "email",
].filter(Boolean) as ("push" | "sms" | "email")[]
),
}),
onSuccess: () => {
toast.success("Broadcast queued for delivery")
setTitle("")
setMessage("")
},
onError: () =>
toast.error(
"Could not send. Ensure POST /admin/notifications/broadcast exists.",
),
})
if (!canSendBroadcast) {
return <Navigate to="/admin/dashboard" replace />
}
const toggleChannel = (key: keyof typeof channels) => {
setChannels((c) => ({ ...c, [key]: !c[key] }))
}
const channelActive =
channels.push || channels.sms || channels.email
return (
<div className="space-y-8 max-w-2xl mx-auto bg-white p-4 min-h-screen">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Send notification
</h1>
<p className="text-gray-500 mt-1">
Super Admins and Admins can broadcast via push, SMS, and email.
Delivery depends on user preferences and channel configuration.
</p>
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
Channels
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-3">
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => toggleChannel("push")}
className={cn(
"rounded-none border p-4 text-left transition-colors",
channels.push
? "border-primary bg-primary/5"
: "border-gray-200 opacity-70 hover:opacity-100",
)}
>
<Bell className="h-5 w-5 mb-2 text-gray-700" />
<div className="text-xs font-bold uppercase tracking-wider">
Push
</div>
</button>
<button
type="button"
onClick={() => toggleChannel("sms")}
className={cn(
"rounded-none border p-4 text-left transition-colors",
channels.sms
? "border-primary bg-primary/5"
: "border-gray-200 opacity-70 hover:opacity-100",
)}
>
<MessageSquare className="h-5 w-5 mb-2 text-gray-700" />
<div className="text-xs font-bold uppercase tracking-wider">
SMS
</div>
</button>
<button
type="button"
onClick={() => toggleChannel("email")}
className={cn(
"rounded-none border p-4 text-left transition-colors",
channels.email
? "border-primary bg-primary/5"
: "border-gray-200 opacity-70 hover:opacity-100",
)}
>
<Mail className="h-5 w-5 mb-2 text-gray-700" />
<div className="text-xs font-bold uppercase tracking-wider">
Email
</div>
</button>
</div>
</CardContent>
</Card>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
Audience
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<Select
value={audience}
onValueChange={(v) =>
setAudience(
v as
| "all_end_users"
| "system_users_only"
| "everyone_with_access",
)
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_end_users">
All platform customers
</SelectItem>
<SelectItem value="system_users_only">
Panel users only (support &amp; admins)
</SelectItem>
<SelectItem value="everyone_with_access">
Everyone with an account
</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
Message
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-1">
<Label htmlFor="bc-title">Title</Label>
<Input
id="bc-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="rounded-none"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="bc-body">Body</Label>
<textarea
id="bc-body"
value={message}
onChange={(e) => setMessage(e.target.value)}
className="flex min-h-[160px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<Button
className="rounded-none gap-2 w-full sm:w-auto"
disabled={
mutation.isPending || !title.trim() || !message.trim() || !channelActive
}
onClick={() => mutation.mutate()}
>
<Send className="h-4 w-4" />
Send broadcast
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,336 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Search, Plus, Pencil } from "lucide-react"
import { faqService } from "@/services"
import type { FaqAudience, FaqEntry } from "@/services/faq.service"
import { useAdminRole } from "@/hooks/use-admin-role"
import { toast } from "sonner"
export default function FaqSupportPage() {
const { canEdit } = useAdminRole()
const queryClient = useQueryClient()
const [tab, setTab] = useState<"browse" | "manage">("browse")
const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">(
"ALL",
)
const [search, setSearch] = useState("")
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<FaqEntry | null>(null)
const [form, setForm] = useState({
question: "",
answer: "",
audience: "ALL" as FaqAudience,
})
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "faq", search, audienceFilter],
queryFn: () =>
faqService.list({
limit: 100,
search: search.trim() || undefined,
audience:
audienceFilter === "ALL" ? undefined : audienceFilter,
}),
})
const saveMutation = useMutation({
mutationFn: async () => {
if (editing) {
return faqService.update(editing.id, {
question: form.question,
answer: form.answer,
audience: form.audience,
})
}
return faqService.create({
question: form.question,
answer: form.answer,
audience: form.audience,
isPublished: true,
})
},
onSuccess: () => {
toast.success(editing ? "FAQ updated" : "FAQ published")
queryClient.invalidateQueries({ queryKey: ["admin", "faq"] })
setOpen(false)
setEditing(null)
setForm({
question: "",
answer: "",
audience: "ALL",
})
},
onError: () => toast.error("Save failed"),
})
const browseItems =
data?.data?.filter((e) => e.isPublished !== false) ?? []
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
FAQ &amp; support
</h1>
<p className="text-gray-500 mt-1 max-w-2xl">
Browse answers for end users and internal system users. Editors can
publish entries and control which audience sees each question.
</p>
</div>
<Tabs
value={canEdit ? tab : "browse"}
onValueChange={(v) => {
if (canEdit) setTab(v as "browse" | "manage")
}}
className="space-y-6"
>
<TabsList className="rounded-none bg-gray-100 p-1">
<TabsTrigger value="browse" className="rounded-none">
Browse
</TabsTrigger>
{canEdit && (
<TabsTrigger value="manage" className="rounded-none">
Manage content
</TabsTrigger>
)}
</TabsList>
<TabsContent value="browse" className="space-y-4 mt-2">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search questions…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select
value={audienceFilter}
onValueChange={(v) =>
setAudienceFilter(v as FaqAudience | "ALL")
}
>
<SelectTrigger className="w-[200px] rounded-none h-9 text-xs">
<SelectValue placeholder="Audience" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All audiences</SelectItem>
<SelectItem value="END_USER">End users</SelectItem>
<SelectItem value="SYSTEM_USER">System users</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<p className="text-sm text-amber-700 bg-amber-50 border px-4 py-3">
Connect <code className="text-xs">GET /admin/faq</code> to load entries.
</p>
)}
<div className="space-y-3">
{isLoading ? (
<p className="text-gray-400 animate-pulse py-12 text-center">
Loading
</p>
) : browseItems.length ? (
browseItems.map((faq) => (
<Card
key={faq.id}
className="border shadow-none rounded-none"
>
<CardHeader className="pb-2 border-b border-gray-100">
<div className="flex items-start justify-between gap-4">
<CardTitle className="text-base font-bold text-gray-900">
{faq.question}
</CardTitle>
<Badge variant="outline" className="text-[10px] rounded-none shrink-0">
{faq.audience === "ALL"
? "Everyone"
: faq.audience === "END_USER"
? "End users"
: "System users"}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-4 text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
{faq.answer}
</CardContent>
</Card>
))
) : (
<p className="text-center text-gray-400 py-12 italic text-sm">
No FAQs to display.
</p>
)}
</div>
</TabsContent>
{canEdit && (
<TabsContent value="manage" className="mt-2 space-y-4">
<div className="flex justify-end">
<Button
className="rounded-none gap-2"
onClick={() => {
setEditing(null)
setForm({
question: "",
answer: "",
audience: "ALL",
})
setOpen(true)
}}
>
<Plus className="h-4 w-4" />
New question
</Button>
</div>
<Card className="border shadow-none rounded-none">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Question
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Audience
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.data?.map((faq) => (
<tr key={faq.id}>
<td className="px-6 py-3 text-sm font-medium max-w-md line-clamp-2">
{faq.question}
</td>
<td className="px-6 py-3 text-xs">{faq.audience}</td>
<td className="px-6 py-3 text-right">
<Button
variant="ghost"
size="sm"
className="rounded-none h-8"
onClick={() => {
setEditing(faq)
setForm({
question: faq.question,
answer: faq.answer,
audience: faq.audience,
})
setOpen(true)
}}
>
<Pencil className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="rounded-none max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editing ? "Edit FAQ" : "New FAQ entry"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1">
<Label>Audience</Label>
<Select
value={form.audience}
onValueChange={(v) =>
setForm((f) => ({ ...f, audience: v as FaqAudience }))
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Everyone (users &amp; system)</SelectItem>
<SelectItem value="END_USER">Platform customers only</SelectItem>
<SelectItem value="SYSTEM_USER">Panel / support staff only</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="faq-q">Question</Label>
<Input
id="faq-q"
value={form.question}
onChange={(e) =>
setForm((f) => ({ ...f, question: e.target.value }))
}
className="rounded-none"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="faq-a">Answer</Label>
<textarea
id="faq-a"
value={form.answer}
onChange={(e) =>
setForm((f) => ({ ...f, answer: e.target.value }))
}
className="flex min-h-[140px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="rounded-none"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
className="rounded-none"
disabled={
saveMutation.isPending ||
!form.question.trim() ||
!form.answer.trim()
}
onClick={() => saveMutation.mutate()}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,282 @@
import { useState } from "react"
import { Navigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Search, UserPlus } from "lucide-react"
import { systemMemberService } from "@/services"
import { useAdminRole } from "@/hooks/use-admin-role"
import { AdminRole } from "@/lib/admin-roles"
import { toast } from "sonner"
export default function SystemMembersPage() {
const { canAccessSystemMembers, canEdit } = useAdminRole()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
email: "",
firstName: "",
lastName: "",
password: "",
role: AdminRole.CUSTOMER_SUPPORT,
})
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "system-members", page, search],
queryFn: () =>
systemMemberService.list({
page,
limit: 10,
search: search.trim() || undefined,
}),
enabled: canAccessSystemMembers,
})
const createMutation = useMutation({
mutationFn: () => systemMemberService.create(form),
onSuccess: () => {
toast.success("System user created")
queryClient.invalidateQueries({ queryKey: ["admin", "system-members"] })
setOpen(false)
setForm({
email: "",
firstName: "",
lastName: "",
password: "",
role: AdminRole.CUSTOMER_SUPPORT,
})
},
onError: () => toast.error("Failed to create user"),
})
if (!canAccessSystemMembers) {
return <Navigate to="/admin/dashboard" replace />
}
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-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
System users
</h1>
<p className="text-gray-500 mt-1 max-w-xl">
Internal staff who can access this panel. Actors: System Admin (full
access), Admin (view &amp; edit), Customer Support (view-only on most
areas; cannot manage this list).
</p>
</div>
{canEdit && (
<Button
className="rounded-none gap-2"
onClick={() => setOpen(true)}
>
<UserPlus className="h-4 w-4" />
Add system user
</Button>
)}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Directory
</CardTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search name or email…"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</div>
</CardHeader>
<CardContent className="p-0">
{error && (
<p className="p-6 text-sm text-amber-700 bg-amber-50 border-b">
Could not reach{" "}
<code className="text-xs">GET /admin/system-members</code>. Add this
route on your API to populate the table.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Name
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Email
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Panel role
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={4}
className="px-6 py-16 text-center text-gray-400 animate-pulse"
>
Loading
</td>
</tr>
) : data?.data?.length ? (
data.data.map((m) => (
<tr key={m.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
{m.firstName} {m.lastName}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{m.email}
</td>
<td className="px-6 py-4">
<Badge variant="outline" className="rounded-none text-[10px]">
{m.role}
</Badge>
</td>
<td className="px-6 py-4 text-right text-xs text-gray-600">
{m.isActive ? "Active" : "Disabled"}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={4}
className="px-6 py-16 text-center text-gray-400 italic text-sm"
>
No system users loaded.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="rounded-none max-w-md">
<DialogHeader>
<DialogTitle>Add system user</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1">
<Label htmlFor="sm-email">Email</Label>
<Input
id="sm-email"
value={form.email}
onChange={(e) =>
setForm((f) => ({ ...f, email: e.target.value }))
}
className="rounded-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-1">
<Label htmlFor="sm-fn">First name</Label>
<Input
id="sm-fn"
value={form.firstName}
onChange={(e) =>
setForm((f) => ({ ...f, firstName: e.target.value }))
}
className="rounded-none"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="sm-ln">Last name</Label>
<Input
id="sm-ln"
value={form.lastName}
onChange={(e) =>
setForm((f) => ({ ...f, lastName: e.target.value }))
}
className="rounded-none"
/>
</div>
</div>
<div className="grid gap-1">
<Label htmlFor="sm-pw">Temporary password</Label>
<Input
id="sm-pw"
type="password"
value={form.password}
onChange={(e) =>
setForm((f) => ({ ...f, password: e.target.value }))
}
className="rounded-none"
/>
</div>
<div className="grid gap-1">
<Label>Role</Label>
<Select
value={form.role}
onValueChange={(role) =>
setForm((f) => ({ ...f, role }))
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AdminRole.SUPER_ADMIN}>
System Admin full access
</SelectItem>
<SelectItem value={AdminRole.ADMIN}>
Admin view &amp; edit
</SelectItem>
<SelectItem value={AdminRole.CUSTOMER_SUPPORT}>
Customer Support view (no member management)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="rounded-none" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
className="rounded-none"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,219 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Search, CheckCircle2, XCircle } from "lucide-react"
import { subscriptionTransactionService } from "@/services"
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service"
export default function SubscriptionTransactionsPage() {
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded")
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const status: SubscriptionPaymentStatus =
tab === "succeeded" ? "SUCCEEDED" : "FAILED"
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search],
queryFn: () =>
subscriptionTransactionService.getTransactions({
status,
page,
limit: 10,
search: search.trim() || undefined,
}),
})
const formatMoney = (amount: number, currency: string) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
)
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Subscription transactions
</h1>
<p className="text-gray-500 mt-1">
Successful charges and failed attempts for platform subscriptions.
</p>
</div>
<Tabs
value={tab}
onValueChange={(v) => {
setTab(v as "succeeded" | "failed")
setPage(1)
}}
className="space-y-6"
>
<TabsList className="rounded-none bg-gray-100 p-1">
<TabsTrigger value="succeeded" className="rounded-none gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Successful payments
</TabsTrigger>
<TabsTrigger value="failed" className="rounded-none gap-2">
<XCircle className="h-4 w-4 text-red-600" />
Failed payments
</TabsTrigger>
</TabsList>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
{tab === "succeeded"
? "Completed subscription charges"
: "Declined or errored charges"}
</CardTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search email, ref, user…"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</div>
</CardHeader>
<CardContent className="p-0">
{error && (
<p className="p-6 text-sm text-red-600">
Unable to load transactions. Ensure the API exposes{" "}
<code className="text-xs bg-gray-100 px-1">
GET /admin/subscription-transactions
</code>
.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Plan
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Provider / Ref
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Date
</th>
{tab === "failed" && (
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Reason
</th>
)}
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 animate-pulse"
>
Loading
</td>
</tr>
) : data?.data && data.data.length > 0 ? (
data.data.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm">
<div className="font-semibold text-gray-900">
{row.userEmail}
</div>
<div className="text-[11px] text-gray-500">
{row.userId}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{row.planName}
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatMoney(row.amount, row.currency)}
</td>
<td className="px-6 py-4 text-xs text-gray-600">
<div>{row.provider}</div>
{row.providerRef && (
<div className="text-[10px] font-mono text-gray-400 mt-0.5">
{row.providerRef}
</div>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(row.createdAt).toLocaleString()}
</td>
{tab === "failed" && (
<td className="px-6 py-4 text-xs text-red-700 max-w-[200px]">
{row.failureReason ?? "—"}
</td>
)}
<td className="px-6 py-4 text-right">
<Badge
variant="outline"
className="rounded-none text-[10px] uppercase"
>
{row.status}
</Badge>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 italic text-sm"
>
No rows for this filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
{data && data.totalPages > 1 && (
<div className="flex justify-between items-center px-6 py-3 border-t text-xs text-gray-600">
<span>
Page {data.page} of {data.totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
className="underline disabled:opacity-40"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<button
type="button"
className="underline disabled:opacity-40"
disabled={page >= data.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
)}
</CardContent>
</Card>
</Tabs>
</div>
)
}

View File

@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { authService } from "@/services" import { authService } from "@/services"
import { hasPanelAccess } from "@/lib/admin-roles"
import { errorTracker } from "@/lib/error-tracker" import { errorTracker } from "@/lib/error-tracker"
import type { ApiError, LocationState } from "@/types/error.types" import type { ApiError, LocationState } from "@/types/error.types"
@ -27,9 +28,8 @@ export default function LoginPage() {
try { try {
const response = await authService.login({ email, password }) const response = await authService.login({ email, password })
// Check if user is admin if (!hasPanelAccess(response.user.role)) {
if (response.user.role !== 'ADMIN') { toast.error("Access denied. Staff panel credentials required.")
toast.error("Access denied. Admin privileges required.")
setIsLoading(false) setIsLoading(false)
return return
} }

View File

@ -1,4 +1,5 @@
import apiClient from './api/client' import apiClient from './api/client'
import { hasPanelAccess } from '@/lib/admin-roles'
export interface LoginRequest { export interface LoginRequest {
email: string email: string
@ -88,11 +89,11 @@ class AuthService {
} }
/** /**
* Check if user is admin * Legacy: true for any staff panel role
*/ */
isAdmin(): boolean { isAdmin(): boolean {
const user = this.getCurrentUser() const user = this.getCurrentUser()
return user?.role === 'ADMIN' return hasPanelAccess(user?.role)
} }
} }

View File

@ -0,0 +1,67 @@
import apiClient from "./api/client"
/** Who can see this FAQ entry in product surfaces */
export type FaqAudience = "END_USER" | "SYSTEM_USER" | "ALL"
export interface FaqEntry {
id: string
question: string
answer: string
audience: FaqAudience
sortOrder: number
isPublished: boolean
createdAt: string
updatedAt?: string
}
export interface PaginatedFaqs {
data: FaqEntry[]
total: number
page: number
limit: number
totalPages: number
}
class FaqService {
async list(params?: {
page?: number
limit?: number
audience?: FaqAudience
search?: string
}): Promise<PaginatedFaqs> {
const response = await apiClient.get<PaginatedFaqs>("/admin/faq", {
params,
})
return response.data
}
async create(data: {
question: string
answer: string
audience: FaqAudience
sortOrder?: number
isPublished?: boolean
}): Promise<FaqEntry> {
const response = await apiClient.post<FaqEntry>("/admin/faq", data)
return response.data
}
async update(
id: string,
data: Partial<
Pick<
FaqEntry,
"question" | "answer" | "audience" | "sortOrder" | "isPublished"
>
>,
): Promise<FaqEntry> {
const response = await apiClient.patch<FaqEntry>(`/admin/faq/${id}`, data)
return response.data
}
async remove(id: string): Promise<void> {
await apiClient.delete(`/admin/faq/${id}`)
}
}
export const faqService = new FaqService()

View File

@ -11,6 +11,10 @@ export { dashboardService } from "./dashboard.service";
export { notificationService } from "./notification.service"; export { notificationService } from "./notification.service";
export { paymentService } from "./payment.service"; export { paymentService } from "./payment.service";
export { invoiceService } from "./invoice.service"; export { invoiceService } from "./invoice.service";
export { subscriptionTransactionService } from "./subscription-transaction.service";
export { systemMemberService } from "./system-member.service";
export { issueService } from "./issue.service";
export { faqService } from "./faq.service";
// Export types // Export types
export type { LoginRequest, LoginResponse } from "./auth.service"; export type { LoginRequest, LoginResponse } from "./auth.service";
@ -62,3 +66,10 @@ export type {
ProformaFilters, ProformaFilters,
ProformaRequestFilters, ProformaRequestFilters,
} from "./invoice.service"; } from "./invoice.service";
export type {
SubscriptionTransaction,
SubscriptionPaymentStatus,
} from "./subscription-transaction.service";
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
export type { SupportIssue, IssueStatus } from "./issue.service";
export type { FaqEntry, FaqAudience } from "./faq.service";

View File

@ -0,0 +1,63 @@
import apiClient from "./api/client"
export type IssueStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "CLOSED"
export type IssueReporterType = "USER" | "SYSTEM_USER"
export interface SupportIssue {
id: string
title: string
description: string
status: IssueStatus
reporterType: IssueReporterType
reporterEmail: string
reporterUserId?: string
priority: "LOW" | "MEDIUM" | "HIGH"
createdAt: string
updatedAt?: string
}
export interface IssueFilters {
page?: number
limit?: number
status?: IssueStatus
search?: string
}
export interface PaginatedIssues {
data: SupportIssue[]
total: number
page: number
limit: number
totalPages: number
}
class IssueService {
async list(filters: IssueFilters = {}): Promise<PaginatedIssues> {
const response = await apiClient.get<PaginatedIssues>("/admin/issues", {
params: filters,
})
return response.data
}
async create(data: {
title: string
description: string
priority?: SupportIssue["priority"]
}): Promise<SupportIssue> {
const response = await apiClient.post<SupportIssue>("/admin/issues", data)
return response.data
}
async updateStatus(
id: string,
status: IssueStatus,
): Promise<SupportIssue> {
const response = await apiClient.patch<SupportIssue>(
`/admin/issues/${id}`,
{ status },
)
return response.data
}
}
export const issueService = new IssueService()

View File

@ -58,6 +58,22 @@ class NotificationService {
await apiClient.post('/notifications/read-all') await apiClient.post('/notifications/read-all')
} }
/**
* Broadcast push, SMS, and/or email (Super Admin & Admin only)
*/
async sendBroadcast(data: {
title: string
message: string
channels: ('push' | 'sms' | 'email')[]
audience?: 'all_end_users' | 'system_users_only' | 'everyone_with_access'
}): Promise<{ id: string }> {
const response = await apiClient.post<{ id: string }>(
'/admin/notifications/broadcast',
data,
)
return response.data
}
/** /**
* Send notification (ADMIN only) * Send notification (ADMIN only)
*/ */

View File

@ -0,0 +1,47 @@
import apiClient from "./api/client"
export type SubscriptionPaymentStatus = "SUCCEEDED" | "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
}
export interface SubscriptionTransactionFilters {
page?: number
limit?: number
status?: SubscriptionPaymentStatus
search?: string
}
export interface PaginatedSubscriptionTx {
data: SubscriptionTransaction[]
total: number
page: number
limit: number
totalPages: number
}
class SubscriptionTransactionService {
async getTransactions(
filters: SubscriptionTransactionFilters = {},
): Promise<PaginatedSubscriptionTx> {
const response = await apiClient.get<PaginatedSubscriptionTx>(
"/admin/subscription-transactions",
{ params: filters },
)
return response.data
}
}
export const subscriptionTransactionService =
new SubscriptionTransactionService()

View File

@ -0,0 +1,65 @@
import apiClient from "./api/client"
/** Internal staff that operate the admin / support panel */
export interface SystemMember {
id: string
email: string
firstName: string
lastName: string
/** Panel role: SUPER_ADMIN | ADMIN | CUSTOMER_SUPPORT */
role: string
isActive: boolean
createdAt: string
updatedAt?: string
}
export interface CreateSystemMemberPayload {
email: string
firstName: string
lastName: string
password: string
role: string
}
export interface PaginatedSystemMembers {
data: SystemMember[]
total: number
page: number
limit: number
totalPages: number
}
class SystemMemberService {
async list(params?: {
page?: number
limit?: number
search?: string
}): Promise<PaginatedSystemMembers> {
const response = await apiClient.get<PaginatedSystemMembers>(
"/admin/system-members",
{ params },
)
return response.data
}
async create(data: CreateSystemMemberPayload): Promise<SystemMember> {
const response = await apiClient.post<SystemMember>(
"/admin/system-members",
data,
)
return response.data
}
async update(
id: string,
data: Partial<Pick<SystemMember, "firstName" | "lastName" | "role" | "isActive">>,
): Promise<SystemMember> {
const response = await apiClient.patch<SystemMember>(
`/admin/system-members/${id}`,
data,
)
return response.data
}
}
export const systemMemberService = new SystemMemberService()