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:
parent
4795822065
commit
23ab82a726
16
src/App.tsx
16
src/App.tsx
|
|
@ -30,6 +30,11 @@ import PaymentRequestsPage from "@/pages/admin/payments/payment-requests";
|
|||
import InvoicesPage from "@/pages/admin/invoices/invoices-list";
|
||||
import ProformaPage from "@/pages/admin/invoices/proforma-list";
|
||||
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() {
|
||||
return (
|
||||
|
|
@ -87,6 +92,17 @@ function App() {
|
|||
path="admin/payment-requests"
|
||||
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/proforma" element={<ProformaPage />} />
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ import {
|
|||
ADMIN_SEARCH_ROUTES,
|
||||
groupAdminSearchRoutes,
|
||||
} from "@/config/admin-search-routes";
|
||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||
|
||||
export function AdminQuickSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { canAccessSystemMembers, canSendBroadcast } = useAdminRole();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -32,7 +34,15 @@ export function AdminQuickSearch() {
|
|||
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()];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import {
|
|||
Gauge,
|
||||
DollarSign,
|
||||
HardDrive,
|
||||
ArrowRightLeft,
|
||||
UserCog,
|
||||
LifeBuoy,
|
||||
HelpCircle,
|
||||
Send,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface AdminSearchRoute {
|
||||
|
|
@ -92,6 +97,15 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
|||
group: "Commerce",
|
||||
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",
|
||||
path: "/admin/users",
|
||||
|
|
@ -100,6 +114,15 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
|||
group: "People & activity",
|
||||
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",
|
||||
path: "/admin/logs",
|
||||
|
|
@ -116,6 +139,24 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
|||
group: "Operations",
|
||||
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",
|
||||
path: "/admin/maintenance",
|
||||
|
|
@ -244,13 +285,24 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
|||
group: "Operations",
|
||||
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 = [
|
||||
"Overview",
|
||||
"Commerce",
|
||||
"People & activity",
|
||||
"Support",
|
||||
"Operations",
|
||||
"Communications",
|
||||
"Security",
|
||||
"Analytics",
|
||||
]
|
||||
|
|
|
|||
29
src/hooks/use-admin-role.ts
Normal file
29
src/hooks/use-admin-role.ts
Normal 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),
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -18,6 +18,11 @@ import {
|
|||
Receipt,
|
||||
FileSearch,
|
||||
ClipboardList,
|
||||
ArrowRightLeft,
|
||||
UserCog,
|
||||
LifeBuoy,
|
||||
HelpCircle,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
||||
|
|
@ -31,6 +36,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { roleLabel } from "@/lib/admin-roles";
|
||||
import { authService } from "@/services";
|
||||
|
||||
interface User {
|
||||
|
|
@ -40,7 +46,15 @@ interface User {
|
|||
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: Receipt, label: "Invoices", path: "/admin/invoices" },
|
||||
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
|
||||
|
|
@ -55,8 +69,21 @@ const adminNavigationItems = [
|
|||
label: "Payment Requests",
|
||||
path: "/admin/payment-requests",
|
||||
},
|
||||
{
|
||||
icon: ArrowRightLeft,
|
||||
label: "Subscription transactions",
|
||||
path: "/admin/transactions/subscriptions",
|
||||
},
|
||||
{ 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: LifeBuoy, label: "Issues", path: "/admin/issues" },
|
||||
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
|
||||
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
||||
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
||||
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
||||
|
|
@ -64,6 +91,12 @@ const adminNavigationItems = [
|
|||
{ icon: Shield, label: "Security", path: "/admin/security" },
|
||||
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
|
||||
{ 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() {
|
||||
|
|
@ -136,24 +169,28 @@ export function AppShell() {
|
|||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||
{adminNavigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
isActive(item.path)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{adminNavigationItems
|
||||
.filter((item) =>
|
||||
item.visible ? item.visible(user?.role) : true,
|
||||
)
|
||||
.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
isActive(item.path)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Section */}
|
||||
|
|
@ -169,6 +206,11 @@ export function AppShell() {
|
|||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user?.email || "admin@example.com"}
|
||||
</p>
|
||||
{user?.role && (
|
||||
<p className="text-[10px] font-semibold text-primary/80 uppercase tracking-wider mt-0.5">
|
||||
{roleLabel(user.role)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
55
src/lib/admin-roles.ts
Normal file
55
src/lib/admin-roles.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
316
src/pages/admin/issues/index.tsx
Normal file
316
src/pages/admin/issues/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
214
src/pages/admin/notifications/broadcast.tsx
Normal file
214
src/pages/admin/notifications/broadcast.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
336
src/pages/admin/support/faq.tsx
Normal file
336
src/pages/admin/support/faq.tsx
Normal 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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
282
src/pages/admin/system-members/index.tsx
Normal file
282
src/pages/admin/system-members/index.tsx
Normal 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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
219
src/pages/admin/transactions/subscription-transactions.tsx
Normal file
219
src/pages/admin/transactions/subscription-transactions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"
|
|||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { authService } from "@/services"
|
||||
import { hasPanelAccess } from "@/lib/admin-roles"
|
||||
import { errorTracker } from "@/lib/error-tracker"
|
||||
import type { ApiError, LocationState } from "@/types/error.types"
|
||||
|
||||
|
|
@ -27,9 +28,8 @@ export default function LoginPage() {
|
|||
try {
|
||||
const response = await authService.login({ email, password })
|
||||
|
||||
// Check if user is admin
|
||||
if (response.user.role !== 'ADMIN') {
|
||||
toast.error("Access denied. Admin privileges required.")
|
||||
if (!hasPanelAccess(response.user.role)) {
|
||||
toast.error("Access denied. Staff panel credentials required.")
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import apiClient from './api/client'
|
||||
import { hasPanelAccess } from '@/lib/admin-roles'
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
|
|
@ -88,11 +89,11 @@ class AuthService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
* Legacy: true for any staff panel role
|
||||
*/
|
||||
isAdmin(): boolean {
|
||||
const user = this.getCurrentUser()
|
||||
return user?.role === 'ADMIN'
|
||||
return hasPanelAccess(user?.role)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
67
src/services/faq.service.ts
Normal file
67
src/services/faq.service.ts
Normal 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()
|
||||
|
|
@ -11,6 +11,10 @@ export { dashboardService } from "./dashboard.service";
|
|||
export { notificationService } from "./notification.service";
|
||||
export { paymentService } from "./payment.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 type { LoginRequest, LoginResponse } from "./auth.service";
|
||||
|
|
@ -62,3 +66,10 @@ export type {
|
|||
ProformaFilters,
|
||||
ProformaRequestFilters,
|
||||
} 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";
|
||||
|
|
|
|||
63
src/services/issue.service.ts
Normal file
63
src/services/issue.service.ts
Normal 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()
|
||||
|
|
@ -58,6 +58,22 @@ class NotificationService {
|
|||
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)
|
||||
*/
|
||||
|
|
|
|||
47
src/services/subscription-transaction.service.ts
Normal file
47
src/services/subscription-transaction.service.ts
Normal 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()
|
||||
65
src/services/system-member.service.ts
Normal file
65
src/services/system-member.service.ts
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user