From 23ab82a72639a54d37af809d2f562c98fef3bd4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ckirukib=E2=80=9D?= <“kirubeljkl679@gmail.com”> Date: Wed, 15 Apr 2026 10:45:10 +0300 Subject: [PATCH] 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 --- src/App.tsx | 16 + src/components/admin-quick-search.tsx | 12 +- src/config/admin-search-routes.ts | 52 +++ src/hooks/use-admin-role.ts | 29 ++ src/layouts/app-shell.tsx | 82 +++-- src/lib/admin-roles.ts | 55 +++ src/pages/admin/issues/index.tsx | 316 ++++++++++++++++ src/pages/admin/notifications/broadcast.tsx | 214 +++++++++++ src/pages/admin/support/faq.tsx | 336 ++++++++++++++++++ src/pages/admin/system-members/index.tsx | 282 +++++++++++++++ .../subscription-transactions.tsx | 219 ++++++++++++ src/pages/login/index.tsx | 6 +- src/services/auth.service.ts | 5 +- src/services/faq.service.ts | 67 ++++ src/services/index.ts | 11 + src/services/issue.service.ts | 63 ++++ src/services/notification.service.ts | 16 + .../subscription-transaction.service.ts | 47 +++ src/services/system-member.service.ts | 65 ++++ 19 files changed, 1867 insertions(+), 26 deletions(-) create mode 100644 src/hooks/use-admin-role.ts create mode 100644 src/lib/admin-roles.ts create mode 100644 src/pages/admin/issues/index.tsx create mode 100644 src/pages/admin/notifications/broadcast.tsx create mode 100644 src/pages/admin/support/faq.tsx create mode 100644 src/pages/admin/system-members/index.tsx create mode 100644 src/pages/admin/transactions/subscription-transactions.tsx create mode 100644 src/services/faq.service.ts create mode 100644 src/services/issue.service.ts create mode 100644 src/services/subscription-transaction.service.ts create mode 100644 src/services/system-member.service.ts diff --git a/src/App.tsx b/src/App.tsx index 1164c6e..8383a30 100644 --- a/src/App.tsx +++ b/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={} /> + } + /> + } /> + } /> + } /> + } + /> } /> } /> { 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 ( diff --git a/src/config/admin-search-routes.ts b/src/config/admin-search-routes.ts index 82cff7b..6296288 100644 --- a/src/config/admin-search-routes.ts +++ b/src/config/admin-search-routes.ts @@ -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", ] diff --git a/src/hooks/use-admin-role.ts b/src/hooks/use-admin-role.ts new file mode 100644 index 0000000..d646bc8 --- /dev/null +++ b/src/hooks/use-admin-role.ts @@ -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), + } + }, []) +} diff --git a/src/layouts/app-shell.tsx b/src/layouts/app-shell.tsx index 2eaa5ff..42da1bf 100644 --- a/src/layouts/app-shell.tsx +++ b/src/layouts/app-shell.tsx @@ -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 */} - {adminNavigationItems.map((item) => { - const Icon = item.icon; - return ( - - - {item.label} - - ); - })} + {adminNavigationItems + .filter((item) => + item.visible ? item.visible(user?.role) : true, + ) + .map((item) => { + const Icon = item.icon; + return ( + + + {item.label} + + ); + })} {/* User Section */} @@ -169,6 +206,11 @@ export function AppShell() { {user?.email || "admin@example.com"} + {user?.role && ( + + {roleLabel(user.role)} + + )} ([ + 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" + } +} diff --git a/src/pages/admin/issues/index.tsx b/src/pages/admin/issues/index.tsx new file mode 100644 index 0000000..cd10a81 --- /dev/null +++ b/src/pages/admin/issues/index.tsx @@ -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 = { + 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 ( + + + + + Issues + + + Customers and internal system users can report problems here. Support + staff with edit access can move tickets through the workflow. + + + setOpen(true)} + > + + Report issue + + + + + + + Queue + + + + { + setSearch(e.target.value) + setPage(1) + }} + /> + + + + {error && ( + + Wire up GET /admin/issues on your API + to list tickets. + + )} + + + + + + Title + + + Reporter + + + Type + + + Priority + + + Updated + + + Status + + + + + {isLoading ? ( + + + Loading… + + + ) : data?.data?.length ? ( + data.data.map((issue) => ( + + + {issue.title} + + {issue.description} + + + + {issue.reporterEmail} + + {issue.reporterType} + + + — + + {issue.priority} + + + {issue.updatedAt + ? new Date(issue.updatedAt).toLocaleString() + : new Date(issue.createdAt).toLocaleString()} + + + {canEdit ? ( + + statusMutation.mutate({ + id: issue.id, + status: v as IssueStatus, + }) + } + > + + + + + Open + + In progress + + Resolved + Closed + + + ) : ( + + {issue.status} + + )} + + + )) + ) : ( + + + No issues loaded. + + + )} + + + + + + + + + + Report an issue + + + + Title + + setNewIssue((n) => ({ ...n, title: e.target.value })) + } + className="rounded-none" + /> + + + Description + + 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" + /> + + + Priority + + setNewIssue((n) => ({ + ...n, + priority: v as typeof newIssue.priority, + })) + } + > + + + + + Low + Medium + High + + + + + + setOpen(false)} + > + Cancel + + createMutation.mutate()} + > + Submit + + + + + + ) +} diff --git a/src/pages/admin/notifications/broadcast.tsx b/src/pages/admin/notifications/broadcast.tsx new file mode 100644 index 0000000..ac4445a --- /dev/null +++ b/src/pages/admin/notifications/broadcast.tsx @@ -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 + } + + const toggleChannel = (key: keyof typeof channels) => { + setChannels((c) => ({ ...c, [key]: !c[key] })) + } + + const channelActive = + channels.push || channels.sms || channels.email + + return ( + + + + Send notification + + + Super Admins and Admins can broadcast via push, SMS, and email. + Delivery depends on user preferences and channel configuration. + + + + + + + Channels + + + + + 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", + )} + > + + + Push + + + 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", + )} + > + + + SMS + + + 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", + )} + > + + + Email + + + + + + + + + + Audience + + + + + setAudience( + v as + | "all_end_users" + | "system_users_only" + | "everyone_with_access", + ) + } + > + + + + + + All platform customers + + + Panel users only (support & admins) + + + Everyone with an account + + + + + + + + + + Message + + + + + Title + setTitle(e.target.value)} + className="rounded-none" + /> + + + Body + 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" + /> + + mutation.mutate()} + > + + Send broadcast + + + + + ) +} diff --git a/src/pages/admin/support/faq.tsx b/src/pages/admin/support/faq.tsx new file mode 100644 index 0000000..1210f5a --- /dev/null +++ b/src/pages/admin/support/faq.tsx @@ -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( + "ALL", + ) + const [search, setSearch] = useState("") + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(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 ( + + + + FAQ & support + + + Browse answers for end users and internal system users. Editors can + publish entries and control which audience sees each question. + + + + { + if (canEdit) setTab(v as "browse" | "manage") + }} + className="space-y-6" + > + + + Browse + + {canEdit && ( + + Manage content + + )} + + + + + + + setSearch(e.target.value)} + /> + + + setAudienceFilter(v as FaqAudience | "ALL") + } + > + + + + + All audiences + End users + System users + + + + + {error && ( + + Connect GET /admin/faq to load entries. + + )} + + + {isLoading ? ( + + Loading… + + ) : browseItems.length ? ( + browseItems.map((faq) => ( + + + + + {faq.question} + + + {faq.audience === "ALL" + ? "Everyone" + : faq.audience === "END_USER" + ? "End users" + : "System users"} + + + + + {faq.answer} + + + )) + ) : ( + + No FAQs to display. + + )} + + + + {canEdit && ( + + + { + setEditing(null) + setForm({ + question: "", + answer: "", + audience: "ALL", + }) + setOpen(true) + }} + > + + New question + + + + + + + + + + Question + + + Audience + + + Actions + + + + + {data?.data?.map((faq) => ( + + + {faq.question} + + {faq.audience} + + { + setEditing(faq) + setForm({ + question: faq.question, + answer: faq.answer, + audience: faq.audience, + }) + setOpen(true) + }} + > + + + + + ))} + + + + + + + )} + + + + + + + {editing ? "Edit FAQ" : "New FAQ entry"} + + + + + Audience + + setForm((f) => ({ ...f, audience: v as FaqAudience })) + } + > + + + + + Everyone (users & system) + Platform customers only + Panel / support staff only + + + + + Question + + setForm((f) => ({ ...f, question: e.target.value })) + } + className="rounded-none" + /> + + + Answer + + 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" + /> + + + + setOpen(false)} + > + Cancel + + saveMutation.mutate()} + > + Save + + + + + + ) +} diff --git a/src/pages/admin/system-members/index.tsx b/src/pages/admin/system-members/index.tsx new file mode 100644 index 0000000..1b0632b --- /dev/null +++ b/src/pages/admin/system-members/index.tsx @@ -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 + } + + return ( + + + + + System users + + + 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). + + + {canEdit && ( + setOpen(true)} + > + + Add system user + + )} + + + + + + Directory + + + + { + setSearch(e.target.value) + setPage(1) + }} + /> + + + + {error && ( + + Could not reach{" "} + GET /admin/system-members. Add this + route on your API to populate the table. + + )} + + + + + + Name + + + Email + + + Panel role + + + Status + + + + + {isLoading ? ( + + + Loading… + + + ) : data?.data?.length ? ( + data.data.map((m) => ( + + + {m.firstName} {m.lastName} + + + {m.email} + + + + {m.role} + + + + {m.isActive ? "Active" : "Disabled"} + + + )) + ) : ( + + + No system users loaded. + + + )} + + + + + + + + + + Add system user + + + + Email + + setForm((f) => ({ ...f, email: e.target.value })) + } + className="rounded-none" + /> + + + + First name + + setForm((f) => ({ ...f, firstName: e.target.value })) + } + className="rounded-none" + /> + + + Last name + + setForm((f) => ({ ...f, lastName: e.target.value })) + } + className="rounded-none" + /> + + + + Temporary password + + setForm((f) => ({ ...f, password: e.target.value })) + } + className="rounded-none" + /> + + + Role + + setForm((f) => ({ ...f, role })) + } + > + + + + + + System Admin — full access + + + Admin — view & edit + + + Customer Support — view (no member management) + + + + + + + setOpen(false)}> + Cancel + + createMutation.mutate()} + > + Create + + + + + + ) +} diff --git a/src/pages/admin/transactions/subscription-transactions.tsx b/src/pages/admin/transactions/subscription-transactions.tsx new file mode 100644 index 0000000..d42e9e2 --- /dev/null +++ b/src/pages/admin/transactions/subscription-transactions.tsx @@ -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 ( + + + + Subscription transactions + + + Successful charges and failed attempts for platform subscriptions. + + + + { + setTab(v as "succeeded" | "failed") + setPage(1) + }} + className="space-y-6" + > + + + + Successful payments + + + + Failed payments + + + + + + + {tab === "succeeded" + ? "Completed subscription charges" + : "Declined or errored charges"} + + + + { + setSearch(e.target.value) + setPage(1) + }} + /> + + + + {error && ( + + Unable to load transactions. Ensure the API exposes{" "} + + GET /admin/subscription-transactions + + . + + )} + + + + + + User + + + Plan + + + Amount + + + Provider / Ref + + + Date + + {tab === "failed" && ( + + Reason + + )} + + Status + + + + + {isLoading ? ( + + + Loading… + + + ) : data?.data && data.data.length > 0 ? ( + data.data.map((row) => ( + + + + {row.userEmail} + + + {row.userId} + + + + {row.planName} + + + {formatMoney(row.amount, row.currency)} + + + {row.provider} + {row.providerRef && ( + + {row.providerRef} + + )} + + + {new Date(row.createdAt).toLocaleString()} + + {tab === "failed" && ( + + {row.failureReason ?? "—"} + + )} + + + {row.status} + + + + )) + ) : ( + + + No rows for this filter. + + + )} + + + + {data && data.totalPages > 1 && ( + + + Page {data.page} of {data.totalPages} + + + setPage((p) => Math.max(1, p - 1))} + > + Previous + + = data.totalPages} + onClick={() => setPage((p) => p + 1)} + > + Next + + + + )} + + + + + ) +} diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index e0f12d0..6839645 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -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 } diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 55d45df..9c93a81 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -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) } } diff --git a/src/services/faq.service.ts b/src/services/faq.service.ts new file mode 100644 index 0000000..050047e --- /dev/null +++ b/src/services/faq.service.ts @@ -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 { + const response = await apiClient.get("/admin/faq", { + params, + }) + return response.data + } + + async create(data: { + question: string + answer: string + audience: FaqAudience + sortOrder?: number + isPublished?: boolean + }): Promise { + const response = await apiClient.post("/admin/faq", data) + return response.data + } + + async update( + id: string, + data: Partial< + Pick< + FaqEntry, + "question" | "answer" | "audience" | "sortOrder" | "isPublished" + > + >, + ): Promise { + const response = await apiClient.patch(`/admin/faq/${id}`, data) + return response.data + } + + async remove(id: string): Promise { + await apiClient.delete(`/admin/faq/${id}`) + } +} + +export const faqService = new FaqService() diff --git a/src/services/index.ts b/src/services/index.ts index 32df9b2..1eb6c7e 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -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"; diff --git a/src/services/issue.service.ts b/src/services/issue.service.ts new file mode 100644 index 0000000..5e70a8d --- /dev/null +++ b/src/services/issue.service.ts @@ -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 { + const response = await apiClient.get("/admin/issues", { + params: filters, + }) + return response.data + } + + async create(data: { + title: string + description: string + priority?: SupportIssue["priority"] + }): Promise { + const response = await apiClient.post("/admin/issues", data) + return response.data + } + + async updateStatus( + id: string, + status: IssueStatus, + ): Promise { + const response = await apiClient.patch( + `/admin/issues/${id}`, + { status }, + ) + return response.data + } +} + +export const issueService = new IssueService() diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 76a8af7..aea44ff 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -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) */ diff --git a/src/services/subscription-transaction.service.ts b/src/services/subscription-transaction.service.ts new file mode 100644 index 0000000..5af3cc2 --- /dev/null +++ b/src/services/subscription-transaction.service.ts @@ -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 { + const response = await apiClient.get( + "/admin/subscription-transactions", + { params: filters }, + ) + return response.data + } +} + +export const subscriptionTransactionService = + new SubscriptionTransactionService() diff --git a/src/services/system-member.service.ts b/src/services/system-member.service.ts new file mode 100644 index 0000000..7ca4456 --- /dev/null +++ b/src/services/system-member.service.ts @@ -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 { + const response = await apiClient.get( + "/admin/system-members", + { params }, + ) + return response.data + } + + async create(data: CreateSystemMemberPayload): Promise { + const response = await apiClient.post( + "/admin/system-members", + data, + ) + return response.data + } + + async update( + id: string, + data: Partial>, + ): Promise { + const response = await apiClient.patch( + `/admin/system-members/${id}`, + data, + ) + return response.data + } +} + +export const systemMemberService = new SystemMemberService()
{user?.email || "admin@example.com"}
+ {roleLabel(user.role)} +
+ Customers and internal system users can report problems here. Support + staff with edit access can move tickets through the workflow. +
+ Wire up GET /admin/issues on your API + to list tickets. +
GET /admin/issues
+ {issue.description} +
+ Super Admins and Admins can broadcast via push, SMS, and email. + Delivery depends on user preferences and channel configuration. +
+ Browse answers for end users and internal system users. Editors can + publish entries and control which audience sees each question. +
+ Connect GET /admin/faq to load entries. +
GET /admin/faq
+ Loading… +
+ No FAQs to display. +
+ 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). +
+ Could not reach{" "} + GET /admin/system-members. Add this + route on your API to populate the table. +
GET /admin/system-members
+ Successful charges and failed attempts for platform subscriptions. +
+ Unable to load transactions. Ensure the API exposes{" "} + + GET /admin/subscription-transactions + + . +
+ GET /admin/subscription-transactions +