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 InvoicesPage from "@/pages/admin/invoices/invoices-list";
|
||||||
import ProformaPage from "@/pages/admin/invoices/proforma-list";
|
import ProformaPage from "@/pages/admin/invoices/proforma-list";
|
||||||
import ProformaRequestsPage from "@/pages/admin/invoices/proforma-requests";
|
import ProformaRequestsPage from "@/pages/admin/invoices/proforma-requests";
|
||||||
|
import SubscriptionTransactionsPage from "@/pages/admin/transactions/subscription-transactions";
|
||||||
|
import SystemMembersPage from "@/pages/admin/system-members";
|
||||||
|
import IssuesPage from "@/pages/admin/issues";
|
||||||
|
import FaqSupportPage from "@/pages/admin/support/faq";
|
||||||
|
import NotificationBroadcastPage from "@/pages/admin/notifications/broadcast";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,6 +92,17 @@ function App() {
|
||||||
path="admin/payment-requests"
|
path="admin/payment-requests"
|
||||||
element={<PaymentRequestsPage />}
|
element={<PaymentRequestsPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/transactions/subscriptions"
|
||||||
|
element={<SubscriptionTransactionsPage />}
|
||||||
|
/>
|
||||||
|
<Route path="admin/system-members" element={<SystemMembersPage />} />
|
||||||
|
<Route path="admin/issues" element={<IssuesPage />} />
|
||||||
|
<Route path="admin/support/faq" element={<FaqSupportPage />} />
|
||||||
|
<Route
|
||||||
|
path="admin/notifications/broadcast"
|
||||||
|
element={<NotificationBroadcastPage />}
|
||||||
|
/>
|
||||||
<Route path="admin/invoices" element={<InvoicesPage />} />
|
<Route path="admin/invoices" element={<InvoicesPage />} />
|
||||||
<Route path="admin/proforma" element={<ProformaPage />} />
|
<Route path="admin/proforma" element={<ProformaPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ import {
|
||||||
ADMIN_SEARCH_ROUTES,
|
ADMIN_SEARCH_ROUTES,
|
||||||
groupAdminSearchRoutes,
|
groupAdminSearchRoutes,
|
||||||
} from "@/config/admin-search-routes";
|
} from "@/config/admin-search-routes";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
|
||||||
export function AdminQuickSearch() {
|
export function AdminQuickSearch() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { canAccessSystemMembers, canSendBroadcast } = useAdminRole();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -32,7 +34,15 @@ export function AdminQuickSearch() {
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const grouped = groupAdminSearchRoutes(ADMIN_SEARCH_ROUTES);
|
const searchRoutes = ADMIN_SEARCH_ROUTES.filter((r) => {
|
||||||
|
if (r.path === "/admin/system-members" && !canAccessSystemMembers)
|
||||||
|
return false;
|
||||||
|
if (r.path === "/admin/notifications/broadcast" && !canSendBroadcast)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = groupAdminSearchRoutes(searchRoutes);
|
||||||
const groups = [...grouped.keys()];
|
const groups = [...grouped.keys()];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ import {
|
||||||
Gauge,
|
Gauge,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
ArrowRightLeft,
|
||||||
|
UserCog,
|
||||||
|
LifeBuoy,
|
||||||
|
HelpCircle,
|
||||||
|
Send,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
export interface AdminSearchRoute {
|
export interface AdminSearchRoute {
|
||||||
|
|
@ -92,6 +97,15 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
||||||
group: "Commerce",
|
group: "Commerce",
|
||||||
icon: FileClock,
|
icon: FileClock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "subscription-txns",
|
||||||
|
path: "/admin/transactions/subscriptions",
|
||||||
|
title: "Subscription transactions",
|
||||||
|
description:
|
||||||
|
"Successful and failed subscription charges for the platform.",
|
||||||
|
group: "Commerce",
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
path: "/admin/users",
|
path: "/admin/users",
|
||||||
|
|
@ -100,6 +114,15 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
||||||
group: "People & activity",
|
group: "People & activity",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "system-members",
|
||||||
|
path: "/admin/system-members",
|
||||||
|
title: "System users",
|
||||||
|
description:
|
||||||
|
"Internal panel accounts (system admin, admin, customer support).",
|
||||||
|
group: "People & activity",
|
||||||
|
icon: UserCog,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "logs",
|
id: "logs",
|
||||||
path: "/admin/logs",
|
path: "/admin/logs",
|
||||||
|
|
@ -116,6 +139,24 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
||||||
group: "Operations",
|
group: "Operations",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "issues",
|
||||||
|
path: "/admin/issues",
|
||||||
|
title: "Issues",
|
||||||
|
description:
|
||||||
|
"Support tickets reported by customers or internal system users.",
|
||||||
|
group: "Support",
|
||||||
|
icon: LifeBuoy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "faq-support",
|
||||||
|
path: "/admin/support/faq",
|
||||||
|
title: "FAQ & support",
|
||||||
|
description:
|
||||||
|
"Published Q&A for end users and staff; editors manage entries.",
|
||||||
|
group: "Support",
|
||||||
|
icon: HelpCircle,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "maintenance",
|
id: "maintenance",
|
||||||
path: "/admin/maintenance",
|
path: "/admin/maintenance",
|
||||||
|
|
@ -244,13 +285,24 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
||||||
group: "Operations",
|
group: "Operations",
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "broadcast",
|
||||||
|
path: "/admin/notifications/broadcast",
|
||||||
|
title: "Send notification",
|
||||||
|
description:
|
||||||
|
"Broadcast push, SMS, and email to selected audiences (admins).",
|
||||||
|
group: "Communications",
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const GROUP_ORDER = [
|
const GROUP_ORDER = [
|
||||||
"Overview",
|
"Overview",
|
||||||
"Commerce",
|
"Commerce",
|
||||||
"People & activity",
|
"People & activity",
|
||||||
|
"Support",
|
||||||
"Operations",
|
"Operations",
|
||||||
|
"Communications",
|
||||||
"Security",
|
"Security",
|
||||||
"Analytics",
|
"Analytics",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
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 { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -18,6 +18,11 @@ import {
|
||||||
Receipt,
|
Receipt,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
ArrowRightLeft,
|
||||||
|
UserCog,
|
||||||
|
LifeBuoy,
|
||||||
|
HelpCircle,
|
||||||
|
Send,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
||||||
|
|
@ -31,6 +36,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { roleLabel } from "@/lib/admin-roles";
|
||||||
import { authService } from "@/services";
|
import { authService } from "@/services";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
|
@ -40,7 +46,15 @@ interface User {
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminNavigationItems = [
|
type NavItem = {
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
/** Omit = visible to all panel roles */
|
||||||
|
visible?: (role: string | undefined) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminNavigationItems: NavItem[] = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||||
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" },
|
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" },
|
||||||
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
|
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
|
||||||
|
|
@ -55,8 +69,21 @@ const adminNavigationItems = [
|
||||||
label: "Payment Requests",
|
label: "Payment Requests",
|
||||||
path: "/admin/payment-requests",
|
path: "/admin/payment-requests",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
label: "Subscription transactions",
|
||||||
|
path: "/admin/transactions/subscriptions",
|
||||||
|
},
|
||||||
{ icon: Users, label: "Users", path: "/admin/users" },
|
{ icon: Users, label: "Users", path: "/admin/users" },
|
||||||
|
{
|
||||||
|
icon: UserCog,
|
||||||
|
label: "System users",
|
||||||
|
path: "/admin/system-members",
|
||||||
|
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN",
|
||||||
|
},
|
||||||
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
||||||
|
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
|
||||||
|
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
|
||||||
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
||||||
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
||||||
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
||||||
|
|
@ -64,6 +91,12 @@ const adminNavigationItems = [
|
||||||
{ icon: Shield, label: "Security", path: "/admin/security" },
|
{ icon: Shield, label: "Security", path: "/admin/security" },
|
||||||
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
|
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
|
||||||
{ icon: Heart, label: "System Health", path: "/admin/health" },
|
{ icon: Heart, label: "System Health", path: "/admin/health" },
|
||||||
|
{
|
||||||
|
icon: Send,
|
||||||
|
label: "Send notification",
|
||||||
|
path: "/admin/notifications/broadcast",
|
||||||
|
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
|
@ -136,24 +169,28 @@ export function AppShell() {
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||||
{adminNavigationItems.map((item) => {
|
{adminNavigationItems
|
||||||
const Icon = item.icon;
|
.filter((item) =>
|
||||||
return (
|
item.visible ? item.visible(user?.role) : true,
|
||||||
<Link
|
)
|
||||||
key={item.path}
|
.map((item) => {
|
||||||
to={item.path}
|
const Icon = item.icon;
|
||||||
className={cn(
|
return (
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
<Link
|
||||||
isActive(item.path)
|
key={item.path}
|
||||||
? "bg-primary text-primary-foreground"
|
to={item.path}
|
||||||
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
className={cn(
|
||||||
)}
|
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
>
|
isActive(item.path)
|
||||||
<Icon className="w-5 h-5" />
|
? "bg-primary text-primary-foreground"
|
||||||
{item.label}
|
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
||||||
</Link>
|
)}
|
||||||
);
|
>
|
||||||
})}
|
<Icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
|
|
@ -169,6 +206,11 @@ export function AppShell() {
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{user?.email || "admin@example.com"}
|
{user?.email || "admin@example.com"}
|
||||||
</p>
|
</p>
|
||||||
|
{user?.role && (
|
||||||
|
<p className="text-[10px] font-semibold text-primary/80 uppercase tracking-wider mt-0.5">
|
||||||
|
{roleLabel(user.role)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
55
src/lib/admin-roles.ts
Normal file
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 { Eye, EyeOff } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { authService } from "@/services"
|
import { authService } from "@/services"
|
||||||
|
import { hasPanelAccess } from "@/lib/admin-roles"
|
||||||
import { errorTracker } from "@/lib/error-tracker"
|
import { errorTracker } from "@/lib/error-tracker"
|
||||||
import type { ApiError, LocationState } from "@/types/error.types"
|
import type { ApiError, LocationState } from "@/types/error.types"
|
||||||
|
|
||||||
|
|
@ -27,9 +28,8 @@ export default function LoginPage() {
|
||||||
try {
|
try {
|
||||||
const response = await authService.login({ email, password })
|
const response = await authService.login({ email, password })
|
||||||
|
|
||||||
// Check if user is admin
|
if (!hasPanelAccess(response.user.role)) {
|
||||||
if (response.user.role !== 'ADMIN') {
|
toast.error("Access denied. Staff panel credentials required.")
|
||||||
toast.error("Access denied. Admin privileges required.")
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import apiClient from './api/client'
|
import apiClient from './api/client'
|
||||||
|
import { hasPanelAccess } from '@/lib/admin-roles'
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -88,11 +89,11 @@ class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is admin
|
* Legacy: true for any staff panel role
|
||||||
*/
|
*/
|
||||||
isAdmin(): boolean {
|
isAdmin(): boolean {
|
||||||
const user = this.getCurrentUser()
|
const user = this.getCurrentUser()
|
||||||
return user?.role === 'ADMIN'
|
return hasPanelAccess(user?.role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 { notificationService } from "./notification.service";
|
||||||
export { paymentService } from "./payment.service";
|
export { paymentService } from "./payment.service";
|
||||||
export { invoiceService } from "./invoice.service";
|
export { invoiceService } from "./invoice.service";
|
||||||
|
export { subscriptionTransactionService } from "./subscription-transaction.service";
|
||||||
|
export { systemMemberService } from "./system-member.service";
|
||||||
|
export { issueService } from "./issue.service";
|
||||||
|
export { faqService } from "./faq.service";
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { LoginRequest, LoginResponse } from "./auth.service";
|
export type { LoginRequest, LoginResponse } from "./auth.service";
|
||||||
|
|
@ -62,3 +66,10 @@ export type {
|
||||||
ProformaFilters,
|
ProformaFilters,
|
||||||
ProformaRequestFilters,
|
ProformaRequestFilters,
|
||||||
} from "./invoice.service";
|
} from "./invoice.service";
|
||||||
|
export type {
|
||||||
|
SubscriptionTransaction,
|
||||||
|
SubscriptionPaymentStatus,
|
||||||
|
} from "./subscription-transaction.service";
|
||||||
|
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
|
||||||
|
export type { SupportIssue, IssueStatus } from "./issue.service";
|
||||||
|
export type { FaqEntry, FaqAudience } from "./faq.service";
|
||||||
|
|
|
||||||
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')
|
await apiClient.post('/notifications/read-all')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast push, SMS, and/or email (Super Admin & Admin only)
|
||||||
|
*/
|
||||||
|
async sendBroadcast(data: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
channels: ('push' | 'sms' | 'email')[]
|
||||||
|
audience?: 'all_end_users' | 'system_users_only' | 'everyone_with_access'
|
||||||
|
}): Promise<{ id: string }> {
|
||||||
|
const response = await apiClient.post<{ id: string }>(
|
||||||
|
'/admin/notifications/broadcast',
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification (ADMIN only)
|
* Send notification (ADMIN only)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
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