-
-
- Enable maintenance mode to temporarily disable access to the platform
+
+
+
+
+
+ Activating this will redirect all non-admin traffic to the
+ maintenance landing page.
{!isEnabled && (
-
-
+
+
setMessage(e.target.value)}
+ className="h-11 rounded-none border-gray-200 text-sm font-medium focus-visible:ring-gray-900 shadow-none"
/>
-
- This message will be displayed to users when maintenance mode is enabled
-
)}
{isEnabled && status?.message && (
-
-
-
{status.message}
+
+
+
+ {status.message}
+
)}
+
+ {isLoading && (
+
+ Verifying system status...
+
+ )}
- )
+ );
}
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Audience
+
+
+
+
+
+
+
+
+
+
+ Message
+
+
+
+
+
+ setTitle(e.target.value)}
+ className="rounded-none"
+ />
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/payments/payment-requests.tsx b/src/pages/admin/payments/payment-requests.tsx
new file mode 100644
index 0000000..4964c78
--- /dev/null
+++ b/src/pages/admin/payments/payment-requests.tsx
@@ -0,0 +1,844 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Search,
+ ChevronLeft,
+ ChevronRight,
+ Filter,
+ Plus,
+ Trash2,
+ Loader2,
+ Building2,
+ ListOrdered,
+} from "lucide-react";
+import { paymentService } from "@/services";
+import { cn } from "@/lib/utils";
+import { useAdminRole } from "@/hooks/use-admin-role";
+import { toast } from "sonner";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+export default function PaymentRequestsPage() {
+ const { canCreateBusinessData } = useAdminRole();
+ const queryClient = useQueryClient();
+ const [page, setPage] = useState(1);
+ const [search, setSearch] = useState("");
+
+ // Create Modal State
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [formData, setFormData] = useState
({
+ paymentRequestNumber: `PAYREQ-${new Date().getFullYear()}-${Math.floor(100 + Math.random() * 900)}`,
+ customerName: "",
+ customerEmail: "",
+ customerPhone: "",
+ amount: 0,
+ currency: "USD",
+ issueDate: new Date().toISOString(),
+ dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
+ description: "",
+ notes: "",
+ taxAmount: 0,
+ discountAmount: 0,
+ status: "DRAFT",
+ paymentId: "",
+ customerId: "",
+ accounts: [
+ {
+ bankName: "Yaltopia Bank",
+ accountName: "Yaltopia Tech PLC",
+ accountNumber: "",
+ currency: "ETB",
+ },
+ ],
+ items: [{ description: "", quantity: 1, unitPrice: 0, total: 0 }],
+ });
+
+ const { data: requestsData, isLoading: requestsLoading } = useQuery({
+ queryKey: ["admin", "payment-requests", page, search],
+ queryFn: () =>
+ paymentService.getPaymentRequests({
+ page,
+ limit: 10,
+ search: search || undefined,
+ }),
+ });
+
+ const createMutation = useMutation({
+ mutationFn: (data: any) => paymentService.createPaymentRequest(data),
+ onSuccess: () => {
+ toast.success("Payment request created successfully");
+ setIsModalOpen(false);
+ queryClient.invalidateQueries({
+ queryKey: ["admin", "payment-requests"],
+ });
+ },
+ onError: () => {
+ toast.error("Failed to create payment request");
+ },
+ });
+
+ const handleCreate = (e: React.FormEvent) => {
+ e.preventDefault();
+ createMutation.mutate(formData);
+ };
+
+ const addItem = () => {
+ setFormData({
+ ...formData,
+ items: [
+ ...formData.items,
+ { description: "", quantity: 1, unitPrice: 0, total: 0 },
+ ],
+ });
+ };
+
+ const removeItem = (idx: number) => {
+ setFormData({
+ ...formData,
+ items: formData.items.filter((_: any, i: number) => i !== idx),
+ });
+ };
+
+ const handleItemChange = (idx: number, field: string, value: any) => {
+ const newItems = [...formData.items];
+ newItems[idx] = { ...newItems[idx], [field]: value };
+
+ // Auto-calculate total
+ if (field === "quantity" || field === "unitPrice") {
+ newItems[idx].total = newItems[idx].quantity * newItems[idx].unitPrice;
+ }
+
+ const newAmount = newItems.reduce((sum, item) => sum + item.total, 0);
+ setFormData({ ...formData, items: newItems, amount: newAmount });
+ };
+
+ const addAccount = () => {
+ setFormData({
+ ...formData,
+ accounts: [
+ ...formData.accounts,
+ { bankName: "", accountName: "", accountNumber: "", currency: "ETB" },
+ ],
+ });
+ };
+
+ const removeAccount = (idx: number) => {
+ setFormData({
+ ...formData,
+ accounts: formData.accounts.filter((_: any, i: number) => i !== idx),
+ });
+ };
+
+ const handleAccountChange = (idx: number, field: string, value: any) => {
+ const newAccounts = [...formData.accounts];
+ newAccounts[idx] = { ...newAccounts[idx], [field]: value };
+ setFormData({ ...formData, accounts: newAccounts });
+ };
+
+ const formatCurrency = (amount: number | any) => {
+ const val = typeof amount === "number" ? amount : 0;
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(val);
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "PAID":
+ return "text-emerald-600 bg-emerald-50";
+ case "SENT":
+ return "text-blue-600 bg-blue-50";
+ case "OPENED":
+ return "text-amber-600 bg-amber-50";
+ case "EXPIRED":
+ case "CANCELLED":
+ return "text-red-600 bg-red-50";
+ default:
+ return "text-gray-600 bg-gray-50";
+ }
+ };
+
+ return (
+
+
+
+
+ Payment Requests
+
+
+ Manage outbound customer requests.
+
+
+ {canCreateBusinessData && (
+
+
+
+ )}
+
+
+
+
+
+ Request Queue
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ |
+ Request #
+ |
+
+ Customer
+ |
+
+ Amount
+ |
+
+ Due Date
+ |
+
+ Status
+ |
+
+
+
+ {requestsLoading ? (
+
+ |
+ Loading requests...
+ |
+
+ ) : requestsData?.data && requestsData.data.length > 0 ? (
+ requestsData.data.map((request) => (
+
+ |
+ {request.paymentRequestNumber}
+ |
+
+
+
+ {request.customerName}
+
+
+ {request.customerEmail}
+
+
+ |
+
+ {formatCurrency(request.amount)}
+ |
+
+ {new Date(request.dueDate).toLocaleDateString()}
+ |
+
+
+ {request.status}
+
+ |
+
+ ))
+ ) : (
+
+ |
+ No records found.
+ |
+
+ )}
+
+
+
+
+ {requestsData?.meta && (
+
+
+ Page {requestsData.meta.page} of {requestsData.meta.totalPages}
+
+
+
+
+
+
+ )}
+
+
+ {/* Create Modal */}
+
+
+ );
+}
diff --git a/src/pages/admin/payments/payments-list.tsx b/src/pages/admin/payments/payments-list.tsx
new file mode 100644
index 0000000..83e44f9
--- /dev/null
+++ b/src/pages/admin/payments/payments-list.tsx
@@ -0,0 +1,542 @@
+import React, { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Search,
+ Plus,
+ ChevronLeft,
+ ChevronRight,
+ Pencil,
+ Trash2,
+ Loader2,
+} from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { paymentService } from "@/services";
+import { useAdminRole } from "@/hooks/use-admin-role";
+import { toast } from "sonner";
+import type { Payment } from "@/services/payment.service";
+
+export default function PaymentsListPage() {
+ const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
+ useAdminRole();
+ const queryClient = useQueryClient();
+ const [page, setPage] = useState(1);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [editingPayment, setEditingPayment] = useState(null);
+ const [paymentToDelete, setPaymentToDelete] = useState(null);
+
+ // Form State
+ const [formData, setFormData] = useState>({
+ transactionId: "",
+ amount: 0,
+ currency: "USD",
+ get paymentDate() {
+ return new Date().toISOString().split("T")[0];
+ },
+ paymentMethod: "Credit Card",
+ notes: "",
+ invoiceId: "",
+ });
+
+ const { data: paymentsData, isLoading: paymentsLoading } = useQuery({
+ queryKey: ["admin", "payments", page],
+ queryFn: () => paymentService.getPayments({ page, limit: 10 }),
+ });
+
+ const createMutation = useMutation({
+ mutationFn: (data: any) => paymentService.createPayment(data),
+ onSuccess: () => {
+ toast.success("Payment logged successfully");
+ setIsModalOpen(false);
+ queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
+ },
+ onError: (err: any) => {
+ toast.error(err.response?.data?.message?.[0] || "Failed to log payment");
+ },
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: any }) =>
+ paymentService.updatePayment(id, data),
+ onSuccess: () => {
+ toast.success("Payment record updated");
+ setIsModalOpen(false);
+ queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
+ },
+ onError: (err: any) => {
+ toast.error(
+ err.response?.data?.message?.[0] || "Failed to update payment",
+ );
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => paymentService.deletePayment(id),
+ onSuccess: () => {
+ toast.success("Payment record expunged");
+ setIsDeleteModalOpen(false);
+ queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
+ },
+ onError: () => {
+ toast.error("Failed to delete payment");
+ },
+ });
+
+ const handleOpenCreate = () => {
+ setEditingPayment(null);
+ setFormData({
+ transactionId: `TXN-${Math.floor(100000 + Math.random() * 900000)}`,
+ amount: 0,
+ currency: "USD",
+ paymentDate: new Date().toISOString().split("T")[0],
+ paymentMethod: "Credit Card",
+ notes: "",
+ invoiceId: "",
+ });
+ setIsModalOpen(true);
+ };
+
+ const handleOpenEdit = (payment: Payment) => {
+ setEditingPayment(payment);
+ setFormData({
+ ...payment,
+ paymentDate: new Date(payment.paymentDate).toISOString().split("T")[0],
+ });
+ setIsModalOpen(true);
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (editingPayment) {
+ updateMutation.mutate({ id: editingPayment.id, data: formData });
+ } else {
+ createMutation.mutate(formData);
+ }
+ };
+
+ const formatCurrency = (amount: number | any) => {
+ const val = typeof amount === "number" ? amount : 0;
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: formData.currency || "USD",
+ }).format(val);
+ };
+
+ return (
+
+
+
+
+ Payments
+
+
History of settled transactions.
+
+ {canCreateBusinessData && (
+
+ )}
+
+
+
+
+
+ Transaction History
+
+
+
+
+
+
+
+
+
+
+
+ |
+ Transaction ID
+ |
+
+ Sender
+ |
+
+ Method
+ |
+
+ Amount
+ |
+
+ Date
+ |
+
+ Actions
+ |
+
+
+
+ {paymentsLoading ? (
+
+ |
+ Synchronizing ledger...
+ |
+
+ ) : paymentsData?.data && paymentsData.data.length > 0 ? (
+ paymentsData.data.map((payment) => (
+
+ |
+ {payment.transactionId}
+ |
+
+ {payment.senderName || "Unknown"}
+ {payment.isFlagged && (
+
+ Flagged
+
+ )}
+ |
+
+ {payment.paymentMethod}
+ |
+
+ {formatCurrency(payment.amount)}
+ |
+
+ {new Date(payment.paymentDate).toLocaleDateString()}
+ |
+
+
+ {canEditBusinessData && (
+
+ )}
+ {canDeleteBusinessData && (
+
+ )}
+ {!canEditBusinessData && !canDeleteBusinessData && (
+
+ View Only
+
+ )}
+
+ |
+
+ ))
+ ) : (
+
+ |
+ No records found in transaction history.
+ |
+
+ )}
+
+
+
+
+ {paymentsData?.meta && (
+
+
+ Page {paymentsData.meta.page} of {paymentsData.meta.totalPages}
+
+
+
+
+
+
+ )}
+
+
+ {/* Create/Edit Modal */}
+
+
+ {/* Delete Confirmation */}
+
+
+ );
+}
diff --git a/src/pages/admin/security/api-keys.tsx b/src/pages/admin/security/api-keys.tsx
index 3eed39e..fd8c0a0 100644
--- a/src/pages/admin/security/api-keys.tsx
+++ b/src/pages/admin/security/api-keys.tsx
@@ -1,102 +1,158 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Ban } from "lucide-react"
-import { securityService, type ApiKey } from "@/services"
-import { toast } from "sonner"
-import { format } from "date-fns"
-import type { ApiError } from "@/types/error.types"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Ban, Key, Calendar, User, Zap } from "lucide-react";
+import { securityService, type ApiKey } from "@/services";
+import { toast } from "sonner";
+import { format } from "date-fns";
+import type { ApiError } from "@/types/error.types";
+import { cn } from "@/lib/utils";
export default function ApiKeysPage() {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
const { data: apiKeys, isLoading } = useQuery({
- queryKey: ['admin', 'security', 'api-keys'],
+ queryKey: ["admin", "security", "api-keys"],
queryFn: () => securityService.getAllApiKeys(),
- })
+ });
const revokeMutation = useMutation({
mutationFn: (id: string) => securityService.revokeApiKey(id),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
- toast.success("API key revoked successfully")
+ queryClient.invalidateQueries({
+ queryKey: ["admin", "security", "api-keys"],
+ });
+ toast.success("API access credential revoked");
},
onError: (error) => {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to revoke API key")
+ const apiError = error as ApiError;
+ toast.error(
+ apiError.response?.data?.message || "Failed to revoke access",
+ );
},
- })
+ });
return (
-
-
API Keys
+
+
+
+
+ API Gateway
+
+
+ Management of system access credentials and authentication tokens.
+
+
+
-
-
- All API Keys
+
+
+
+ Credential Registry
+
+
-
- {isLoading ? (
- Loading API keys...
- ) : (
- <>
-
-
-
- Name
- User
- Last Used
- Status
- Actions
-
-
-
- {apiKeys?.map((key: ApiKey) => (
-
- {key.name}
- {key.userId || 'N/A'}
-
- {key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'}
-
-
-
- {key.isActive ? 'Active' : 'Revoked'}
-
-
-
+
+
+
+
+
+ |
+ Key Identifier
+ |
+
+ Operator
+ |
+
+ Last Activity
+ |
+
+ Access Status
+ |
+
+ Actions
+ |
+
+
+
+ {isLoading ? (
+
+ |
+ Retrieving secure credentials...
+ |
+
+ ) : apiKeys && apiKeys.length > 0 ? (
+ apiKeys.map((key: ApiKey) => (
+
+ |
+
+
+
+ {key.name}
+
+
+ |
+
+
+
+ {key.userId || "N/A"}
+
+ |
+
+
+
+ {key.lastUsed
+ ? format(new Date(key.lastUsed), "MMM dd, yyyy")
+ : "Inactive"}
+
+ |
+
+
+ {key.isActive ? "Authorized" : "Deactivated"}
+
+ |
+
{key.isActive && (
)}
-
-
- ))}
-
- |
- {apiKeys?.length === 0 && (
-
- No API keys found
-
- )}
- >
- )}
+
+
+ ))
+ ) : (
+
+ |
+ No API access credentials defined.
+ |
+
+ )}
+
+
+
- )
+ );
}
-
diff --git a/src/pages/admin/security/failed-logins.tsx b/src/pages/admin/security/failed-logins.tsx
index c86364d..36a4742 100644
--- a/src/pages/admin/security/failed-logins.tsx
+++ b/src/pages/admin/security/failed-logins.tsx
@@ -1,105 +1,177 @@
-import { useState } from "react"
-import { useQuery } from "@tanstack/react-query"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Search, Ban } from "lucide-react"
-import { securityService, type FailedLogin } from "@/services"
-import { format } from "date-fns"
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Search, Ban, ChevronLeft, ChevronRight, Filter } from "lucide-react";
+import { securityService, type FailedLogin } from "@/services";
+import { format } from "date-fns";
export default function FailedLoginsPage() {
- const [page] = useState(1)
- const [limit] = useState(50)
- const [search, setSearch] = useState("")
+ const [page, setPage] = useState(1);
+ const [limit] = useState(15);
+ const [search, setSearch] = useState("");
const { data: failedLogins, isLoading } = useQuery({
- queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
+ queryKey: ["admin", "security", "failed-logins", page, limit, search],
queryFn: async () => {
- const params: Record = { page, limit }
- if (search) params.email = search
- return await securityService.getFailedLogins(params)
+ const params: Record = { page, limit };
+ if (search) params.email = search;
+ return await securityService.getFailedLogins(params);
},
- })
+ });
return (
-
-
Failed Login Attempts
+
+
+
+
+ Access Violations
+
+
+ Audit trail for authentication failures and potential threats.
+
+
+
-
-
-
-
Failed Logins
-
-
+
+
+
+ Violation Ledger
+
+
+
+
setSearch(e.target.value)}
/>
+
-
- {isLoading ? (
- Loading failed logins...
- ) : (
- <>
-
-
-
- Email
- IP Address
- User Agent
- Reason
- Attempted At
- Blocked
- Actions
-
-
-
- {failedLogins?.data?.map((login: FailedLogin) => (
-
- {login.email}
- {login.ipAddress}
- {login.ipAddress}
- {login.reason || 'N/A'}
-
- {format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
-
-
-
- N/A
-
-
-
-
+
+ {failedLogins && (
+
+
+ Total Violations: {failedLogins.total || 0}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+
+
+ setPage((p) => p + 1)}
+ disabled={
+ !failedLogins?.data || failedLogins.data.length < limit
+ }
+ >
+
+
+
+
+ )}
- )
+ );
}
-
diff --git a/src/pages/admin/security/index.tsx b/src/pages/admin/security/index.tsx
index 73d3c12..328641b 100644
--- a/src/pages/admin/security/index.tsx
+++ b/src/pages/admin/security/index.tsx
@@ -1,86 +1,89 @@
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
-import { useNavigate } from "react-router-dom"
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import {
+ Shield,
+ AlertTriangle,
+ Key,
+ Gauge,
+ Users,
+ ChevronRight,
+} from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { cn } from "@/lib/utils";
export default function SecurityPage() {
- const navigate = useNavigate()
+ const navigate = useNavigate();
return (
Security
-
navigate('/admin/security/failed-logins')}>
-
-
-
- Failed Logins
-
-
-
-
- View and manage failed login attempts
-
-
-
-
-
navigate('/admin/security/suspicious')}>
-
-
-
- Suspicious Activity
-
-
-
-
- Monitor suspicious IPs and emails
-
-
-
-
-
navigate('/admin/security/api-keys')}>
-
-
-
- API Keys
-
-
-
-
- Manage API keys and tokens
-
-
-
-
-
navigate('/admin/security/rate-limits')}>
-
-
-
- Rate Limits
-
-
-
-
- View rate limit violations
-
-
-
-
-
navigate('/admin/security/sessions')}>
-
-
-
- Active Sessions
-
-
-
-
- Manage active user sessions
-
-
-
+ {[
+ {
+ label: "Failed Logins",
+ description: "View and manage failed login attempts",
+ icon: AlertTriangle,
+ path: "/admin/security/failed-logins",
+ color: "text-rose-600",
+ },
+ {
+ label: "Suspicious Activity",
+ description: "Monitor suspicious IPs and emails",
+ icon: Shield,
+ path: "/admin/security/suspicious",
+ color: "text-amber-600",
+ },
+ {
+ label: "API Keys",
+ description: "Manage API keys and tokens",
+ icon: Key,
+ path: "/admin/security/api-keys",
+ color: "text-blue-600",
+ },
+ {
+ label: "Rate Limits",
+ description: "View rate limit violations",
+ icon: Gauge,
+ path: "/admin/security/rate-limits",
+ color: "text-purple-600",
+ },
+ {
+ label: "Active Sessions",
+ description: "Manage active user sessions",
+ icon: Users,
+ path: "/admin/security/sessions",
+ color: "text-emerald-600",
+ },
+ ].map((item) => (
+
navigate(item.path)}
+ >
+
+
+
+ {item.label}
+
+
+
+
+
+
+
+ {item.label}
+
+
+ {item.description}
+
+
+
+
+
+ ))}
- )
+ );
}
-
diff --git a/src/pages/admin/security/rate-limits.tsx b/src/pages/admin/security/rate-limits.tsx
index 081000d..314c3f6 100644
--- a/src/pages/admin/security/rate-limits.tsx
+++ b/src/pages/admin/security/rate-limits.tsx
@@ -1,65 +1,136 @@
-import { useQuery } from "@tanstack/react-query"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { securityService } from "@/services"
-import type { RateLimitViolation } from "@/types/security.types"
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Gauge, Clock, Activity, AlertTriangle } from "lucide-react";
+import { securityService } from "@/services";
+import type { RateLimitViolation } from "@/types/security.types";
export default function RateLimitsPage() {
const { data: violations, isLoading } = useQuery({
- queryKey: ['admin', 'security', 'rate-limits'],
+ queryKey: ["admin", "security", "rate-limits"],
queryFn: () => securityService.getRateLimitViolations(7),
- })
+ });
return (
-
-
Rate Limit Violations
+
+
+
+
+ Traffic Control
+
+
+ Audit of rate limit violations and anomalous request frequencies.
+
+
+
-
-
- Recent Violations (Last 7 Days)
+
+
+
+ Violation Registry
+
+
-
- {isLoading ? (
- Loading violations...
- ) : (
- <>
-
-
-
- User
- IP Address
- Requests
- Period
-
-
-
- {violations?.map((violation: RateLimitViolation) => (
-
- {violation.userId || 'N/A'}
- {violation.ipAddress}
- {violation.requests}
- {violation.period}
-
- ))}
-
-
- {violations?.length === 0 && (
-
- No rate limit violations found
-
- )}
- >
- )}
+
+
+
+
+
+ |
+ Protocol Identifier
+ |
+
+ Network Origin
+ |
+
+ Velocity
+ |
+
+ Reference Period
+ |
+
+ Severity
+ |
+
+
+
+ {isLoading ? (
+
+ |
+ Analyzing traffic patterns...
+ |
+
+ ) : violations && violations.length > 0 ? (
+ violations.map((violation: RateLimitViolation) => (
+
+ |
+
+
+
+ {violation.userId || "PUBLIC_TRANSIT"}
+
+
+ |
+
+
+ {violation.ipAddress}
+
+ |
+
+
+
+ {violation.requests}{" "}
+
+ REQ
+
+
+ |
+
+
+ {violation.period}
+
+ |
+
+
+ Warning
+
+ |
+
+ ))
+ ) : (
+
+ |
+ Traffic volume within nominal limits.
+ |
+
+ )}
+
+
+
-
- )
-}
+
+
+
+
+ Adaptive Throttling Active
+
+
+ System is currently monitoring high-velocity traffic. Automatic
+ blocking protocols will engage if violation frequency exceeds 5% of
+ total ingress.
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/security/sessions.tsx b/src/pages/admin/security/sessions.tsx
index eaea60f..6318a44 100644
--- a/src/pages/admin/security/sessions.tsx
+++ b/src/pages/admin/security/sessions.tsx
@@ -1,75 +1,132 @@
-import { useQuery } from "@tanstack/react-query"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { LogOut } from "lucide-react"
-import { securityService, type ActiveSession } from "@/services"
-import { format } from "date-fns"
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { LogOut, Monitor, MapPin, Clock } from "lucide-react";
+import { securityService, type ActiveSession } from "@/services";
+import { format } from "date-fns";
export default function SessionsPage() {
const { data: sessions, isLoading } = useQuery({
- queryKey: ['admin', 'security', 'sessions'],
+ queryKey: ["admin", "security", "sessions"],
queryFn: () => securityService.getActiveSessions(),
- })
+ });
return (
-
-
Active Sessions
+
+
+
+
+ Active Sessions
+
+
+ Live oversight of authenticated system access.
+
+
+
-
-
- All Active Sessions
+
+
+
+ Access Registry
+
+
+ {sessions?.length || 0} Authenticated Sessions
+
-
- {isLoading ? (
- Loading sessions...
- ) : (
- <>
-
-
-
- User
- IP Address
- User Agent
- Last Activity
- Actions
-
-
-
- {sessions?.map((session: ActiveSession) => (
-
- {session.userId || 'N/A'}
- {session.ipAddress}
- {session.userAgent}
-
- {format(new Date(session.lastActivity), 'MMM dd, yyyy HH:mm')}
-
-
-
-
+
+
+
+
+
+ |
+ Operator Identity
+ |
+
+ Endpoint
+ |
+
+ Environment
+ |
+
+ Activity Status
+ |
+
+ Control
+ |
+
+
+
+ {isLoading ? (
+
+ |
+ Interrogating active sessions...
+ |
+
+ ) : sessions && sessions.length > 0 ? (
+ sessions.map((session: ActiveSession) => (
+
+ |
+
+
+ {session.userId || "N/A"}
+
+
+ Internal Reference
+
+
+ |
+
+
+
+ {session.ipAddress}
+
+ |
+
+
+
+
+ {session.userAgent}
+
+
+ |
+
+
+
+ {format(new Date(session.lastActivity), "HH:mm:ss")}
+
+ |
+
+
+ Revoke
-
-
- ))}
-
- |
- {sessions?.length === 0 && (
-
- No active sessions found
-
- )}
- >
- )}
+
+
+ ))
+ ) : (
+
+ |
+ No active authenticated sessions.
+ |
+
+ )}
+
+
+
- )
+ );
}
-
diff --git a/src/pages/admin/security/suspicious.tsx b/src/pages/admin/security/suspicious.tsx
index 0f066ce..218932f 100644
--- a/src/pages/admin/security/suspicious.tsx
+++ b/src/pages/admin/security/suspicious.tsx
@@ -1,88 +1,148 @@
-import { useQuery } from "@tanstack/react-query"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Shield, Ban } from "lucide-react"
-import { securityService } from "@/services"
-import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types"
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Shield, Ban, Mail, Globe, AlertTriangle } from "lucide-react";
+import { securityService } from "@/services";
+import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types";
export default function SuspiciousActivityPage() {
const { data: suspicious, isLoading } = useQuery({
- queryKey: ['admin', 'security', 'suspicious'],
+ queryKey: ["admin", "security", "suspicious"],
queryFn: () => securityService.getSuspiciousActivity(),
- })
+ });
return (
-
-
Suspicious Activity
+
+
+
+
+ Anomalous Activity
+
+
+ High-risk identifiers flagged for potential system abuse.
+
+
+
-
-
-
-
-
- Suspicious IP Addresses
-
+
+
+
+
+
+
+ Suspicious Network Ingress
+
+
+
+ Shield Active
+
-
+
{isLoading ? (
- Loading...
+
+ Interrogating global threats...
+
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
-
- {suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => (
-
-
-
{ip.ipAddress}
-
{ip.attempts} attempts
+
+ {suspicious?.suspiciousIPs?.map(
+ (ip: SuspiciousIP, index: number) => (
+
+
+
+ {ip.ipAddress}
+
+
+ {ip.attempts} Flagged Interactions
+
+
+
+ Block IP
+
-
-
- Block
-
-
- ))}
+ ),
+ )}
) : (
-
- No suspicious IPs found
+
+ No high-risk network sources.
)}
-
-
-
-
- Suspicious Emails
-
+
+
+
+
+
+ Flagged Credentials
+
+
+
+ Monitoring
+
-
+
{isLoading ? (
- Loading...
+
+ Screening identity registry...
+
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
-
- {suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => (
-
-
-
{email.email}
-
{email.attempts} attempts
+
+ {suspicious?.suspiciousEmails?.map(
+ (email: SuspiciousEmail, index: number) => (
+
+
+
+ {email.email}
+
+
+ {email.attempts} Security Triggers
+
+
+
+ Block Domain
+
-
-
- Block
-
-
- ))}
+ ),
+ )}
) : (
-
- No suspicious emails found
+
+ No suspicious identity triggers.
)}
-
- )
-}
+
+
+
+
+ Protocol Awareness
+
+
+ Flagged items above are generated based on heuristic analysis of
+ failed signatures, geofence violations, and credential stuffing
+ patterns. Actions taken here apply globally to the ingress proxy.
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/settings/index.tsx b/src/pages/admin/settings/index.tsx
index f84e469..b996d21 100644
--- a/src/pages/admin/settings/index.tsx
+++ b/src/pages/admin/settings/index.tsx
@@ -1,211 +1,143 @@
-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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Switch } from "@/components/ui/switch"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Plus } from "lucide-react"
-import { settingsService, type Setting } from "@/services"
-import { toast } from "sonner"
-import type { ApiError } from "@/types/error.types"
+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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { settingsService, type Setting } from "@/services";
+import { toast } from "sonner";
+import type { ApiError } from "@/types/error.types";
+import { cn } from "@/lib/utils";
export default function SettingsPage() {
- const queryClient = useQueryClient()
- const [selectedCategory, setSelectedCategory] = useState("GENERAL")
- const [createDialogOpen, setCreateDialogOpen] = useState(false)
- const [newSetting, setNewSetting] = useState({
- key: "",
- value: "",
- description: "",
- isPublic: false,
- })
+ const queryClient = useQueryClient();
+ const [selectedCategory, setSelectedCategory] = useState("GENERAL");
const { data: settings, isLoading } = useQuery({
- queryKey: ['admin', 'settings', selectedCategory],
+ queryKey: ["admin", "settings", selectedCategory],
queryFn: () => settingsService.getSettings(selectedCategory),
- })
+ });
const updateSettingMutation = useMutation({
- mutationFn: ({ key, value }: { key: string; value: string }) =>
+ mutationFn: ({ key, value }: { key: string; value: string }) =>
settingsService.updateSetting(key, { value }),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
- toast.success("Setting updated successfully")
+ queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
+ toast.success("Setting updated successfully");
},
onError: (error) => {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to update setting")
+ const apiError = error as ApiError;
+ toast.error(
+ apiError.response?.data?.message || "Failed to update setting",
+ );
},
- })
-
- const createSettingMutation = useMutation({
- mutationFn: (data: {
- key: string
- value: string
- category: string
- description?: string
- isPublic?: boolean
- }) => settingsService.createSetting(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
- toast.success("Setting created successfully")
- setCreateDialogOpen(false)
- setNewSetting({ key: "", value: "", description: "", isPublic: false })
- },
- onError: (error) => {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to create setting")
- },
- })
+ });
const handleSave = (key: string, value: string) => {
- updateSettingMutation.mutate({ key, value })
- }
+ updateSettingMutation.mutate({ key, value });
+ };
- const handleCreate = () => {
- if (!newSetting.key || !newSetting.value) {
- toast.error("Key and value are required")
- return
- }
- createSettingMutation.mutate({
- key: newSetting.key,
- value: newSetting.value,
- category: selectedCategory,
- description: newSetting.description || undefined,
- isPublic: newSetting.isPublic,
- })
- }
+ const categories = [
+ "GENERAL",
+ "EMAIL",
+ "STORAGE",
+ "SECURITY",
+ "API",
+ "FEATURES",
+ ];
return (
-
-
-
System Settings
-
setCreateDialogOpen(true)}>
-
- Create Setting
-
+
+
+
+
+ System Settings
+
+
+ Configure global application parameters.
+
+
+
+ {/* View only access: Create Setting button removed */}
+
-
-
- General
- Email
- Storage
- Security
- API
- Features
+
+
+ {categories.map((cat) => (
+
+ {cat}
+
+ ))}
-
-
-
- {selectedCategory} Settings
+
+
+
+
+ {selectedCategory} Configuration
+
-
+
{isLoading ? (
- Loading settings...
+
+ Fetching system variables...
+
) : settings && settings.length > 0 ? (
settings.map((setting: Setting) => (
-
-
-
+
+
+
+ {setting.description && (
+
+ {setting.description}
+
+ )}
+
+
{
if (e.target.value !== setting.value) {
- handleSave(setting.key, e.target.value)
+ handleSave(setting.key, e.target.value);
}
}}
/>
- {setting.description && (
-
{setting.description}
- )}
))
) : (
-
- No settings found for this category
+
+ No variables defined for this category.
)}
-
- {/* Create Setting Dialog */}
-
- )
+ );
}
-
diff --git a/src/pages/admin/subscriptions/index.tsx b/src/pages/admin/subscriptions/index.tsx
new file mode 100644
index 0000000..070d11c
--- /dev/null
+++ b/src/pages/admin/subscriptions/index.tsx
@@ -0,0 +1,105 @@
+import { useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Edit, Loader2 } from "lucide-react";
+import { subscriptionService } from "@/services";
+
+export default function SubscriptionsAdminPage() {
+ const navigate = useNavigate();
+
+ const { data: plans, isLoading: plansLoading } = useQuery({
+ queryKey: ["admin", "subscription-plans"],
+ queryFn: () => subscriptionService.getAdminPlans(),
+ });
+
+ return (
+
+
+
Subscription Plans
+
+ Manage plan pricing, feature flags, limits, and activation status.
+ Payment history is under{" "}
+ navigate("/admin/transactions/subscriptions")}
+ >
+ Subscriptions → Transactions
+
+ .
+
+
+
+
+
+ All Plans
+
+
+ {plansLoading ? (
+
+
+ Loading plans...
+
+ ) : (
+
+
+
+ Plan
+ Monthly (ETB)
+ Yearly (ETB)
+ Status
+ Actions
+
+
+
+ {plans?.map((plan) => (
+
+
+
+
{plan.displayName}
+
{plan.name}
+
+
+
+ {plan.isFree ? "Free" : plan.monthlyPrice.toLocaleString()}
+
+
+ {plan.isFree ? "Free" : plan.yearlyPrice.toLocaleString()}
+
+
+
+ {plan.isActive ? "Active" : "Inactive"}
+
+
+
+
+ navigate(`/admin/subscriptions/plans/${plan.id}`)
+ }
+ >
+
+ Manage
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/admin/subscriptions/plans/[id].tsx b/src/pages/admin/subscriptions/plans/[id].tsx
new file mode 100644
index 0000000..17876c9
--- /dev/null
+++ b/src/pages/admin/subscriptions/plans/[id].tsx
@@ -0,0 +1,250 @@
+import { useEffect, useState } from "react"
+import { useParams, useNavigate } 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 { Switch } from "@/components/ui/switch"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ArrowLeft, Loader2 } from "lucide-react"
+import { subscriptionService, type PlanFeatures } from "@/services"
+import { toast } from "sonner"
+import type { ApiError } from "@/types/error.types"
+
+function formatFeatureLabel(key: string) {
+ return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+export default function PlanManagementPage() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const [displayName, setDisplayName] = useState("")
+ const [description, setDescription] = useState("")
+ const [monthlyPrice, setMonthlyPrice] = useState("")
+ const [yearlyPrice, setYearlyPrice] = useState("")
+ const [isActive, setIsActive] = useState(true)
+ const [features, setFeatures] = useState
({ features: {}, limits: {} })
+
+ const { data: plan, isLoading } = useQuery({
+ queryKey: ['admin', 'subscription-plans', id],
+ queryFn: () => subscriptionService.getAdminPlan(id!),
+ enabled: !!id,
+ })
+
+ useEffect(() => {
+ if (plan) {
+ setDisplayName(plan.displayName)
+ setDescription(plan.description ?? "")
+ setMonthlyPrice(String(plan.monthlyPrice))
+ setYearlyPrice(String(plan.yearlyPrice))
+ setIsActive(plan.isActive)
+ setFeatures(plan.features)
+ }
+ }, [plan])
+
+ const updatePlanMutation = useMutation({
+ mutationFn: () =>
+ subscriptionService.updatePlan(id!, {
+ displayName,
+ description,
+ monthlyPrice: Number(monthlyPrice),
+ yearlyPrice: Number(yearlyPrice),
+ isActive,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'subscription-plans'] })
+ toast.success("Plan settings updated")
+ },
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to update plan")
+ },
+ })
+
+ const updateFeaturesMutation = useMutation({
+ mutationFn: () => subscriptionService.updatePlanFeatures(id!, features),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'subscription-plans'] })
+ toast.success("Plan features updated")
+ },
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to update features")
+ },
+ })
+
+ const toggleFeature = (key: string) => {
+ setFeatures((prev) => ({
+ ...prev,
+ features: { ...prev.features, [key]: !prev.features[key] },
+ }))
+ }
+
+ const updateLimit = (key: string, value: string) => {
+ const parsed = value.trim() === "" ? null : Number(value)
+ setFeatures((prev) => ({
+ ...prev,
+ limits: { ...prev.limits, [key]: parsed },
+ }))
+ }
+
+ if (isLoading) {
+ return (
+
+
+ Loading plan...
+
+ )
+ }
+
+ if (!plan) {
+ return Plan not found.
+ }
+
+ return (
+
+
+
navigate('/admin/subscriptions')}>
+
+
+
+
{plan.displayName}
+
{plan.name} plan
+
+
+
+
+
+ Pricing & Status
+ Features & Limits
+
+
+
+
+
+ Pricing Settings
+
+
+
+
+ setDisplayName(e.target.value)}
+ />
+
+
+
+ setDescription(e.target.value)}
+ />
+
+ {!plan.isFree && (
+ <>
+
+
+ setMonthlyPrice(e.target.value)}
+ />
+
+
+
+ setYearlyPrice(e.target.value)}
+ />
+
+ >
+ )}
+
+
+
+
+ Inactive plans are hidden from new subscriptions.
+
+
+
+
+ updatePlanMutation.mutate()}
+ disabled={updatePlanMutation.isPending}
+ >
+ {updatePlanMutation.isPending && (
+
+ )}
+ Save Pricing
+
+
+
+
+
+
+
+
+
+ Feature Flags
+
+
+ {Object.entries(features.features).map(([key, enabled]) => (
+
+
+ toggleFeature(key)}
+ />
+
+ ))}
+
+
+
+
+
+ Usage Limits
+
+
+ {Object.entries(features.limits).map(([key, limit]) => (
+
+
+ updateLimit(key, e.target.value)}
+ />
+
+ ))}
+
+
+
+
+
+ updateFeaturesMutation.mutate()}
+ disabled={updateFeaturesMutation.isPending}
+ >
+ {updateFeaturesMutation.isPending && (
+
+ )}
+ Save Features
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/support/faq.tsx b/src/pages/admin/support/faq.tsx
new file mode 100644
index 0000000..f58827c
--- /dev/null
+++ b/src/pages/admin/support/faq.tsx
@@ -0,0 +1,484 @@
+import { useState } from "react";
+import { NavLink } from "react-router-dom";
+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,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Search,
+ Plus,
+ Pencil,
+ HelpCircle,
+ Users,
+ ShieldCheck,
+ Globe,
+ ArrowRight,
+ Library,
+ Settings2,
+ ChevronRight,
+} 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";
+import { cn } from "@/lib/utils";
+
+export default function FaqSupportPage() {
+ const { canEditBusinessData: 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 entry updated" : "FAQ entry published successfully",
+ );
+ queryClient.invalidateQueries({ queryKey: ["admin", "faq"] });
+ setOpen(false);
+ setEditing(null);
+ setForm({ question: "", answer: "", audience: "ALL" });
+ },
+ onError: () =>
+ toast.error("Failure while committing FAQ data to repository"),
+ });
+
+ const browseItems = data?.data?.filter((e) => e.isPublished !== false) ?? [];
+
+ return (
+
+ {/* Header Section */}
+
+
+
+ FAQ & Support
+
+
+ Curate and manage the central intelligence repository for both
+ standard users and internal system administrators.
+
+
+
+
+
+
+ cn(
+ "pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
+ isActive
+ ? "border-primary text-primary"
+ : "border-transparent text-slate-400 hover:text-slate-600",
+ )
+ }
+ >
+ FAQ repository
+
+
+ cn(
+ "pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
+ isActive
+ ? "border-primary text-primary"
+ : "border-transparent text-slate-400 hover:text-slate-600",
+ )
+ }
+ >
+ Support Queue
+
+
+
+
{
+ if (canEdit) setTab(v as "browse" | "manage");
+ }}
+ className="space-y-8"
+ >
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+
+ {error && (
+
+
+
+ Library unreachable. Verify{" "}
+
+ GET /admin/faq
+ {" "}
+ endpoint integrity.
+
+
+ )}
+
+
+ {isLoading ? (
+ Array.from({ length: 4 }).map((_, i) => (
+
+ ))
+ ) : browseItems.length ? (
+ browseItems.map((faq) => (
+
+
+
+
+
+
+
+ {faq.audience === "ALL"
+ ? "Global"
+ : faq.audience === "END_USER"
+ ? "Customer"
+ : "Internal"}
+
+
+
+ {faq.question}
+
+
+
+
+ {faq.answer}
+
+
+ Read Documentation{" "}
+
+
+
+
+ ))
+ ) : (
+
+
+
+
+
+ FAQ Empty
+
+
+ No matches found in the current FAQ repository.
+
+
+
+
+ )}
+
+
+
+ {canEdit && (
+
+
+
{
+ setEditing(null);
+ setForm({ question: "", answer: "", audience: "ALL" });
+ setOpen(true);
+ }}
+ >
+
+ Publish Entry
+
+
+
+
+
+
+
+
+
+ |
+ Intelligence Subject
+ |
+
+ Target Audience
+ |
+
+ Operations
+ |
+
+
+
+ {data?.data?.map((faq) => (
+
+ |
+
+ {faq.question}
+
+ |
+
+
+ {faq.audience === "ALL" ? (
+
+ ) : faq.audience === "END_USER" ? (
+
+ ) : (
+
+ )}
+
+ {faq.audience}
+
+
+ |
+
+ {
+ setEditing(faq);
+ setForm({
+ question: faq.question,
+ answer: faq.answer,
+ audience: faq.audience,
+ });
+ setOpen(true);
+ }}
+ >
+
+
+ |
+
+ ))}
+
+
+
+
+
+
+ )}
+
+
+ {/* FAQ Creation/Edit Modal */}
+
+
+ );
+}
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.
+ |
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/transactions/subscription-transactions.tsx b/src/pages/admin/transactions/subscription-transactions.tsx
new file mode 100644
index 0000000..432b10f
--- /dev/null
+++ b/src/pages/admin/transactions/subscription-transactions.tsx
@@ -0,0 +1,315 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import {
+ Search,
+ CheckCircle2,
+ XCircle,
+ ArrowRightLeft,
+ ChevronLeft,
+ ChevronRight,
+ Filter,
+ CreditCard,
+ User as UserIcon,
+} from "lucide-react";
+import { subscriptionTransactionService } from "@/services";
+import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service";
+import { cn } from "@/lib/utils";
+
+export default function SubscriptionTransactionsPage() {
+ const [tab, setTab] = useState<"completed" | "failed">("completed");
+ const [page, setPage] = useState(1);
+ const [search, setSearch] = useState("");
+ const status: SubscriptionPaymentStatus =
+ tab === "completed" ? "COMPLETED" : "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,
+ minimumFractionDigits: 2,
+ }).format(amount);
+
+ return (
+
+ {/* Header Section */}
+
+
+
+
+ Subscription{" "}
+ Transactions
+
+
+ Monitor real-time platform revenue streams and troubleshoot declined
+ payment attempts across all subscription tiers.
+
+
+
+
+
+
+
+
+
{
+ setTab(v as "completed" | "failed");
+ setPage(1);
+ }}
+ className="w-full sm:w-auto"
+ >
+
+
+
+ Completed
+
+
+
+ Failed
+
+
+
+
+
+
+ {
+ setSearch(e.target.value);
+ setPage(1);
+ }}
+ />
+
+
+
+
+
+ {error && (
+
+
+
+ Failed to synchronize with banking ledger. Verify{" "}
+
+ GET /subscription/admin/transactions
+ {" "}
+ is reachable.
+
+
+ )}
+
+
+
+
+
+ |
+ Subscriber Details
+ |
+
+ Service Plan
+ |
+
+ Transaction Value
+ |
+
+ Financial Gateway
+ |
+
+ Captured At
+ |
+ {tab === "failed" && (
+
+ Resolution Logic
+ |
+ )}
+
+ State
+ |
+
+
+
+ {isLoading ? (
+ Array.from({ length: 5 }).map((_, i) => (
+
+ |
+
+
+ |
+
+ ))
+ ) : 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).toLocaleDateString()}
+
+ {new Date(row.createdAt).toLocaleTimeString()}
+
+
+ |
+ {tab === "failed" && (
+
+
+ {row.failureReason ?? "Unknown Error Logic"}
+
+ |
+ )}
+
+
+ {row.status}
+
+ |
+
+ ))
+ ) : (
+
+
+
+
+
+
+ No matching logs
+
+
+ Adjust your criteria or verify live stream.
+
+
+
+ |
+
+ )}
+
+
+
+
+ {/* Pagination Controls */}
+ {data && data.totalPages > 1 && (
+
+
+ Showing{" "}
+ {data.data.length} of{" "}
+ {data.total} entries
+
+
+
setPage((p) => Math.max(1, p - 1))}
+ >
+ Prev
+
+
+ {data.page} / {data.totalPages}
+
+
= data.totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ Next
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/users/[id]/index.tsx b/src/pages/admin/users/[id]/index.tsx
index 04f5472..76bee60 100644
--- a/src/pages/admin/users/[id]/index.tsx
+++ b/src/pages/admin/users/[id]/index.tsx
@@ -1,9 +1,9 @@
-import { useParams, useNavigate } from "react-router-dom"
-import { useQuery } from "@tanstack/react-query"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { useParams, useNavigate } 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 { Badge } from "@/components/ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
@@ -11,81 +11,131 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
-} from "@/components/ui/dialog"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
-} from "@/components/ui/select"
-import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react"
-import { userService } from "@/services"
-import { format } from "date-fns"
-import { useState } from "react"
-import { toast } from "sonner"
+} from "@/components/ui/select";
+import { ArrowLeft, Edit, Key, Loader2, CreditCard } from "lucide-react";
+import {
+ userService,
+ subscriptionService,
+ type BillingInterval,
+} from "@/services";
+import { useAdminRole } from "@/hooks/use-admin-role";
+import { format } from "date-fns";
+import { useState } from "react";
+import { toast } from "sonner";
+import type { ApiError } from "@/types/error.types";
export default function UserDetailsPage() {
- const { id } = useParams()
- const navigate = useNavigate()
- const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
- const [isSubmitting, setIsSubmitting] = useState(false)
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { canEditUsers } = useAdminRole();
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [assignPlanId, setAssignPlanId] = useState("");
+ const [assignBillingInterval, setAssignBillingInterval] =
+ useState("MONTHLY");
const [editForm, setEditForm] = useState({
- firstName: '',
- lastName: '',
- email: '',
- role: '',
+ firstName: "",
+ lastName: "",
+ email: "",
+ role: "",
isActive: true,
- })
+ });
- const { data: user, isLoading, refetch } = useQuery({
- queryKey: ['admin', 'users', id],
+ const {
+ data: user,
+ isLoading,
+ refetch,
+ } = useQuery({
+ queryKey: ["admin", "users", id],
queryFn: () => userService.getUser(id!),
enabled: !!id,
- })
+ });
+
+ const { data: subscription, isLoading: subscriptionLoading } = useQuery({
+ queryKey: ["admin", "users", id, "subscription"],
+ queryFn: () => subscriptionService.getUserSubscription(id!),
+ enabled: !!id,
+ });
+
+ const { data: plans } = useQuery({
+ queryKey: ["admin", "subscription-plans"],
+ queryFn: () => subscriptionService.getAdminPlans(),
+ });
+
+ const assignPlanMutation = useMutation({
+ mutationFn: () =>
+ subscriptionService.assignPlan(id!, {
+ planId: assignPlanId,
+ billingInterval: assignBillingInterval,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["admin", "users", id, "subscription"],
+ });
+ toast.success("Plan assigned successfully");
+ setIsAssignDialogOpen(false);
+ },
+ onError: (error) => {
+ const apiError = error as ApiError;
+ toast.error(apiError.response?.data?.message || "Failed to assign plan");
+ },
+ });
const handleEditClick = () => {
if (user) {
setEditForm({
- firstName: user.firstName || '',
- lastName: user.lastName || '',
+ firstName: user.firstName || "",
+ lastName: user.lastName || "",
email: user.email,
role: user.role,
isActive: user.isActive,
- })
- setIsEditDialogOpen(true)
+ });
+ setIsEditDialogOpen(true);
}
- }
+ };
const handleSaveEdit = async () => {
try {
- setIsSubmitting(true)
- await userService.updateUser(id!, editForm)
- toast.success("User updated successfully")
- setIsEditDialogOpen(false)
- refetch()
+ setIsSubmitting(true);
+ await userService.updateUser(id!, editForm);
+ toast.success("User updated successfully");
+ setIsEditDialogOpen(false);
+ refetch();
} catch (error) {
- toast.error("Failed to update user")
- console.error('Update error:', error)
+ toast.error("Failed to update user");
+ console.error("Update error:", error);
} finally {
- setIsSubmitting(false)
+ setIsSubmitting(false);
}
- }
+ };
if (isLoading) {
- return Loading user details...
+ return Loading user details...
;
}
if (!user) {
- return User not found
+ return User not found
;
}
return (
-
navigate('/admin/users')}>
+ navigate("/admin/users")}
+ >
User Details
@@ -94,8 +144,12 @@ export default function UserDetailsPage() {
Information
+ Subscription
Statistics
- navigate(`/admin/users/${id}/activity`)}>
+ navigate(`/admin/users/${id}/activity`)}
+ >
Activity
@@ -106,14 +160,27 @@ export default function UserDetailsPage() {
User Information
-
-
- Edit
-
-
-
- Reset Password
-
+ {canEditUsers && (
+ <>
+
+
+ Edit
+
+
+
+ Reset Password
+
+ >
+ )}
+ {!canEditUsers && (
+
+ Immutable View
+
+ )}
@@ -125,7 +192,9 @@ export default function UserDetailsPage() {
Name
-
{user.firstName} {user.lastName}
+
+ {user.firstName} {user.lastName}
+
Role
@@ -133,18 +202,22 @@ export default function UserDetailsPage() {
Status
-
- {user.isActive ? 'Active' : 'Inactive'}
+
+ {user.isActive ? "Active" : "Inactive"}
Created At
-
{format(new Date(user.createdAt), 'PPpp')}
+
+ {format(new Date(user.createdAt), "PPpp")}
+
Updated At
- {user.updatedAt ? format(new Date(user.updatedAt), 'PPpp') : 'N/A'}
+ {user.updatedAt
+ ? format(new Date(user.updatedAt), "PPpp")
+ : "N/A"}
@@ -152,6 +225,78 @@ export default function UserDetailsPage() {
+
+
+
+
+ Subscription Details
+ {canEditUsers && (
+ {
+ setAssignPlanId(subscription?.plan.id ?? "");
+ setIsAssignDialogOpen(true);
+ }}
+ >
+
+ Assign Plan
+
+ )}
+
+
+
+ {subscriptionLoading ? (
+
+
+ Loading subscription...
+
+ ) : subscription ? (
+
+
+
+
Plan
+
{subscription.plan.displayName}
+
+
+
Status
+
{subscription.subscription.status}
+
+
+
Billing
+
{subscription.subscription.billingInterval}
+
+
+
Period End
+
+ {format(new Date(subscription.subscription.currentPeriodEnd), 'PP')}
+
+
+
+
+
+
Usage This Period
+
+ {Object.entries(subscription.usage).map(([key, { used, limit }]) => (
+
+
+ {key.replace(/_/g, ' ')}
+
+
+ {used} / {limit === null ? '∞' : limit}
+
+
+ ))}
+
+
+
+ ) : (
+ No subscription data available.
+ )}
+
+
+
+
@@ -159,7 +304,9 @@ export default function UserDetailsPage() {
Invoices
- {user._count?.invoices || 0}
+
+ {user._count?.invoices || 0}
+
@@ -167,7 +314,9 @@ export default function UserDetailsPage() {
Reports
- {user._count?.reports || 0}
+
+ {user._count?.reports || 0}
+
@@ -175,7 +324,9 @@ export default function UserDetailsPage() {
Documents
- {user._count?.documents || 0}
+
+ {user._count?.documents || 0}
+
@@ -183,7 +334,9 @@ export default function UserDetailsPage() {
Payments
- {user._count?.payments || 0}
+
+ {user._count?.payments || 0}
+
@@ -204,7 +357,9 @@ export default function UserDetailsPage() {
setEditForm({ ...editForm, firstName: e.target.value })}
+ onChange={(e) =>
+ setEditForm({ ...editForm, firstName: e.target.value })
+ }
/>
@@ -212,7 +367,9 @@ export default function UserDetailsPage() {
setEditForm({ ...editForm, lastName: e.target.value })}
+ onChange={(e) =>
+ setEditForm({ ...editForm, lastName: e.target.value })
+ }
/>
@@ -221,12 +378,19 @@ export default function UserDetailsPage() {
id="email"
type="email"
value={editForm.email}
- onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
+ onChange={(e) =>
+ setEditForm({ ...editForm, email: e.target.value })
+ }
/>
-
- setEditForm({ ...editForm, isActive: value === 'active' })}
+
+ setEditForm({ ...editForm, isActive: value === "active" })
+ }
>
@@ -255,17 +421,79 @@ export default function UserDetailsPage() {
- setIsEditDialogOpen(false)} disabled={isSubmitting}>
+ setIsEditDialogOpen(false)}
+ disabled={isSubmitting}
+ >
Cancel
- {isSubmitting && }
+ {isSubmitting && (
+
+ )}
Save Changes
-
- )
-}
+
+
+ );
+}
diff --git a/src/pages/admin/users/index.tsx b/src/pages/admin/users/index.tsx
index 997e904..cb89f21 100644
--- a/src/pages/admin/users/index.tsx
+++ b/src/pages/admin/users/index.tsx
@@ -1,409 +1,232 @@
-import { useState } from "react"
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
-import { useNavigate } from "react-router-dom"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Label } from "@/components/ui/label"
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-react"
-import { userService } from "@/services"
-import { toast } from "sonner"
-import { format } from "date-fns"
-import type { ApiError } from "@/types/error.types"
-
-interface User {
- id: string
- email: string
- firstName: string
- lastName: string
- role: string
- isActive: boolean
- createdAt: string
-}
+ Search,
+ Eye,
+ ChevronLeft,
+ ChevronRight,
+ Filter,
+ Plus,
+} from "lucide-react";
+import { userService } from "@/services";
+import { useAdminRole } from "@/hooks/use-admin-role";
+import { format } from "date-fns";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
export default function UsersPage() {
- const navigate = useNavigate()
- const queryClient = useQueryClient()
- const [page, setPage] = useState(1)
- const [limit] = useState(20)
- const [search, setSearch] = useState("")
- const [roleFilter, setRoleFilter] = useState
("all")
- const [statusFilter, setStatusFilter] = useState("all")
- const [selectedUser, setSelectedUser] = useState(null)
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
- const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
- const [importDialogOpen, setImportDialogOpen] = useState(false)
- const [importFile, setImportFile] = useState(null)
+ const navigate = useNavigate();
+ const { canCreateUsers } = useAdminRole();
+ const [page, setPage] = useState(1);
+ const [limit] = useState(15);
+ const [search, setSearch] = useState("");
+ const [roleFilter] = useState("all");
const { data: usersData, isLoading } = useQuery({
- queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
+ queryKey: ["admin", "users", page, limit, search, roleFilter],
queryFn: async () => {
- const params: Record = { page, limit }
- if (search) params.search = search
- if (roleFilter !== 'all') params.role = roleFilter
- if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
- return await userService.getUsers(params)
+ const params: Record = { page, limit };
+ if (search) params.search = search;
+ if (roleFilter !== "all") params.role = roleFilter;
+ return await userService.getUsers(params);
},
- })
+ });
- const deleteUserMutation = useMutation({
- mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
- userService.deleteUser(id, hard),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
- toast.success("User deleted successfully")
- setDeleteDialogOpen(false)
- },
- onError: (error) => {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to delete user")
- },
- })
-
- const resetPasswordMutation = useMutation({
- mutationFn: (id: string) => userService.resetPassword(id),
- onSuccess: (data) => {
- toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
- setResetPasswordDialogOpen(false)
- },
- onError: (error) => {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to reset password")
- },
- })
-
- const importUsersMutation = useMutation({
- mutationFn: (file: File) => userService.importUsers(file),
- onSuccess: (data) => {
- queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
- toast.success(`Imported ${data.imported} users. ${data.failed} failed.`)
- setImportDialogOpen(false)
- setImportFile(null)
- },
- onError: (error) => {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to import users")
- },
- })
-
- const handleExport = async () => {
- try {
- const blob = await userService.exportUsers('csv')
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `users-${new Date().toISOString()}.csv`
- a.click()
- toast.success("Users exported successfully")
- } catch (error) {
- const apiError = error as ApiError
- toast.error(apiError.response?.data?.message || "Failed to export users")
- }
- }
-
- const handleDelete = () => {
- if (selectedUser) {
- deleteUserMutation.mutate({ id: selectedUser.id, hard: false })
- }
- }
-
- const handleResetPassword = () => {
- if (selectedUser) {
- resetPasswordMutation.mutate(selectedUser.id)
- }
- }
-
- const handleImport = () => {
- if (importFile) {
- importUsersMutation.mutate(importFile)
- }
- }
-
- const handleFileChange = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0]
- if (file) {
- setImportFile(file)
- }
- }
-
- const getRoleBadgeVariant = (role: string) => {
+ const getRoleBadgeColor = (role: string) => {
switch (role) {
- case 'ADMIN':
- return 'destructive'
- case 'USER':
- return 'default'
- case 'VIEWER':
- return 'secondary'
+ case "ADMIN":
+ return "text-rose-600 bg-rose-50 border-rose-100";
+ case "USER":
+ return "text-blue-600 bg-blue-50 border-blue-100";
+ case "VIEWER":
+ return "text-emerald-600 bg-emerald-50 border-emerald-100";
default:
- return 'outline'
+ return "text-gray-600 bg-gray-50 border-gray-100";
}
- }
+ };
return (
-
-
-
Users Management
-
-
setImportDialogOpen(true)}>
-
- Import Users
-
-
-
- Add User
-
+
+
+
+
+ Users
+
+
+ Manage system access and permissions.
+
+
+
+ {canCreateUsers && (
+
+ toast.info("User creation module is being synchronized.")
+ }
+ >
+
+ Add User
+
+ )}
-
-
-
-
All Users
-
-
-
- setSearch(e.target.value)}
- />
-
-
-
-
-
-
- All Roles
- Admin
- User
- Viewer
-
-
-
-
-
-
-
- All Status
- Active
- Inactive
-
-
-
-
- Export
+
+
+
+ User Directory
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ Filter
+
+
+
+
+
+
+
+
+ |
+ User
+ |
+
+ Role
+ |
+
+ Status
+ |
+
+ Created
+ |
+
+ Actions
+ |
+
+
+
+ {isLoading ? (
+
+ |
+ Retrieving user data...
+ |
+
+ ) : usersData?.data && usersData.data.length > 0 ? (
+ usersData.data.map((user: any) => (
+
+ |
+
+
+ {user.firstName} {user.lastName}
+
+
+ {user.email}
+
+
+ |
+
+
+ {user.role}
+
+ |
+
+
+ {user.isActive ? "Active" : "Inactive"}
+
+ |
+
+ {format(new Date(user.createdAt), "MMM dd, yyyy")}
+ |
+
+ navigate(`/admin/users/${user.id}`)}
+ >
+
+
+ |
+
+ ))
+ ) : (
+
+ |
+ No users found.
+ |
+
+ )}
+
+
+
+
+ {usersData && (
+
+
+ Showing {(page - 1) * limit + 1} to{" "}
+ {Math.min(page * limit, usersData.total)} of {usersData.total}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+
+
+ setPage((p) => p + 1)}
+ disabled={page * limit >= usersData.total}
+ >
+
-
-
- {isLoading ? (
- Loading users...
- ) : (
- <>
-
-
-
- Email
- Name
- Role
- Status
- Created At
- Actions
-
-
-
- {usersData?.data?.map((user: User) => (
-
- {user.email}
- {user.firstName} {user.lastName}
-
-
- {user.role}
-
-
-
-
- {user.isActive ? 'Active' : 'Inactive'}
-
-
-
- {format(new Date(user.createdAt), 'MMM dd, yyyy')}
-
-
-
- navigate(`/admin/users/${user.id}`)}
- >
-
-
- {
- setSelectedUser(user)
- setResetPasswordDialogOpen(true)
- }}
- >
-
-
- {
- setSelectedUser(user)
- setDeleteDialogOpen(true)
- }}
- >
-
-
-
-
-
- ))}
-
-
- {usersData?.data?.length === 0 && (
-
- No users found
-
- )}
- {usersData && usersData.total > limit && (
-
-
- Showing {(page - 1) * limit + 1} to {Math.min(page * limit, usersData.total)} of {usersData.total} users
-
-
- setPage(p => Math.max(1, p - 1))}
- disabled={page === 1}
- >
- Previous
-
- setPage(p => p + 1)}
- disabled={page * limit >= usersData.total}
- >
- Next
-
-
-
- )}
- >
- )}
-
+ )}
-
- {/* Delete Dialog */}
-
-
- {/* Reset Password Dialog */}
-
-
- {/* Import Users Dialog */}
-
- )
+ );
}
-
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/pages/notifications/index.tsx b/src/pages/notifications/index.tsx
index 2d0e591..9228a51 100644
--- a/src/pages/notifications/index.tsx
+++ b/src/pages/notifications/index.tsx
@@ -1,277 +1,567 @@
-import { useState, useMemo } from "react"
-import { useQuery } 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 { Badge } from "@/components/ui/badge"
+import { useState, useMemo } 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 { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Separator } from "@/components/ui/separator";
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react"
-import { notificationService } from "@/services/notification.service"
-import { toast } from "sonner"
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Search,
+ CheckCheck,
+ Send,
+ Plus,
+ BellRing,
+ Mail,
+ MessageSquare,
+ History,
+ Target,
+ ArrowRight,
+ ChevronRight,
+ Loader2,
+ Calendar,
+} from "lucide-react";
+import { notificationService } from "@/services/notification.service";
+import type {
+ SendPushNotificationRequest,
+ SendSmsNotificationRequest,
+ SendEmailNotificationRequest,
+} from "@/services/notification.service";
+import { useAdminRole } from "@/hooks/use-admin-role";
+import { toast } from "sonner";
+import { format } from "date-fns";
+import { cn } from "@/lib/utils";
+
+type Channel = "PUSH" | "SMS" | "EMAIL";
export default function NotificationsPage() {
- const [searchQuery, setSearchQuery] = useState("")
- const [typeFilter, setTypeFilter] = useState("")
- const [statusFilter, setStatusFilter] = useState("")
+ const { canSendNotifications } = useAdminRole();
+ const queryClient = useQueryClient();
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeChannel, setActiveChannel] = useState
("PUSH");
+ const [isSendModalOpen, setIsSendModalOpen] = useState(false);
- const { data: notifications, isLoading, refetch } = useQuery({
- queryKey: ['notifications'],
+ // Combined form state
+ const [pushForm, setPushForm] = useState({
+ title: "",
+ body: "",
+ recipientId: "",
+ url: "",
+ icon: "/assets/icon.png",
+ });
+ const [smsForm, setSmsForm] = useState({
+ body: "",
+ recipientPhone: "",
+ });
+ const [emailForm, setEmailForm] = useState({
+ subject: "",
+ body: "",
+ recipientEmail: "",
+ });
+
+ const { data: notifications, isLoading } = useQuery({
+ queryKey: ["notifications"],
queryFn: () => notificationService.getNotifications(),
- })
+ });
const { data: unreadCount } = useQuery({
- queryKey: ['notifications', 'unread-count'],
+ queryKey: ["notifications", "unread-count"],
queryFn: () => notificationService.getUnreadCount(),
- })
+ });
+
+ const pushMutation = useMutation({
+ mutationFn: (data: SendPushNotificationRequest) =>
+ notificationService.sendPushNotification(data),
+ onSuccess: () => {
+ toast.success("Network transmission: Push packet delivered to gateway");
+ setIsSendModalOpen(false);
+ queryClient.invalidateQueries({ queryKey: ["notifications"] });
+ },
+ });
+
+ const smsMutation = useMutation({
+ mutationFn: (data: SendSmsNotificationRequest) =>
+ notificationService.sendSmsNotification(data),
+ onSuccess: () => {
+ toast.success("Cellular uplink: SMS payload queued for broadcast");
+ setIsSendModalOpen(false);
+ queryClient.invalidateQueries({ queryKey: ["notifications"] });
+ },
+ });
+
+ const emailMutation = useMutation({
+ mutationFn: (data: SendEmailNotificationRequest) =>
+ notificationService.sendEmailNotification(data),
+ onSuccess: () => {
+ toast.success("SMTP Handshake: Email broadcast initiated");
+ setIsSendModalOpen(false);
+ queryClient.invalidateQueries({ queryKey: ["notifications"] });
+ },
+ });
- // Client-side filtering
const filteredNotifications = useMemo(() => {
- if (!notifications) return []
-
- return notifications.filter((notification) => {
- // Type filter
- if (typeFilter && notification.type !== typeFilter) return false
-
- // Status filter
- if (statusFilter === 'read' && !notification.isRead) return false
- if (statusFilter === 'unread' && notification.isRead) return false
-
- // Search filter
- if (searchQuery) {
- const query = searchQuery.toLowerCase()
- return (
- notification.title.toLowerCase().includes(query) ||
- notification.message.toLowerCase().includes(query) ||
- notification.recipient.toLowerCase().includes(query)
- )
- }
-
- return true
- })
- }, [notifications, typeFilter, statusFilter, searchQuery])
+ if (!notifications) return [];
+ return notifications.filter((n) => {
+ if (!searchQuery) return true;
+ const q = searchQuery.toLowerCase();
+ return (
+ n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
+ );
+ });
+ }, [notifications, searchQuery]);
- const handleExport = () => {
- try {
- if (!filteredNotifications || filteredNotifications.length === 0) {
- toast.error("No notifications to export")
- return
- }
+ const handleSend = () => {
+ if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
+ else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
+ else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
+ };
- const csvData = [
- ['Notification ID', 'Title', 'Message', 'Type', 'Recipient', 'Status', 'Created Date', 'Read Date'],
- ...filteredNotifications.map(n => [
- n.id,
- n.title,
- n.message,
- n.type,
- n.recipient,
- n.isRead ? 'Read' : 'Unread',
- new Date(n.createdAt).toLocaleString(),
- n.readAt ? new Date(n.readAt).toLocaleString() : '-'
- ])
- ]
-
- const csvContent = csvData.map(row =>
- row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
- ).join('\n')
-
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
- const url = window.URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = `notifications-${new Date().toISOString().split('T')[0]}.csv`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- window.URL.revokeObjectURL(url)
-
- toast.success("Notifications exported successfully!")
- } catch (error) {
- toast.error("Failed to export notifications")
- console.error('Export error:', error)
- }
- }
-
- const handleMarkAsRead = async (id: string) => {
- try {
- await notificationService.markAsRead(id)
- toast.success("Notification marked as read")
- refetch()
- } catch (error) {
- toast.error("Failed to mark notification as read")
- console.error('Mark as read error:', error)
- }
- }
-
- const handleMarkAllAsRead = async () => {
- try {
- await notificationService.markAllAsRead()
- toast.success("All notifications marked as read")
- refetch()
- } catch (error) {
- toast.error("Failed to mark all as read")
- console.error('Mark all as read error:', error)
- }
- }
-
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- })
- }
-
- const formatDateTime = (dateString?: string) => {
- if (!dateString) return '-'
- return new Date(dateString).toLocaleString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })
- }
-
- const getStatusBadge = (isRead: boolean) => {
- return isRead ? 'bg-gray-500' : 'bg-orange-500'
- }
+ const isPending =
+ pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
return (
-
-
-
-
Notifications
- {unreadCount !== undefined && unreadCount > 0 && (
-
- You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
-
- )}
+
+ {/* Header Section */}
+
+
+
+
+
+
+
+ Messaging Hub
+
+
+
+ Command Center
+
+
+ Dispatch multi-channel broadcasts and monitor real-time network
+ telemetry across the Yaltopia mesh.
+
-
- {unreadCount !== undefined && unreadCount > 0 && (
-
-
- Mark All as Read
-
- )}
-
-
- Settings
+
+
+ notificationService.markAllAsRead()}
+ >
+
+ Clear Signal
+
+ setIsSendModalOpen(true)}
+ >
+
+ New Broadcast
-
-
-
-
All Notifications
-
-
-
-
setSearchQuery(e.target.value)}
- />
+
+ {/* Stats / Quick Info */}
+
+
+
+
+
+ System Status
+
+
+ Active
+
+
+
+
+ {unreadCount ?? 0}
+
+
+ Active notifications in current window.
+
+
+
+
+
+ {notifications?.length ?? 0}
+
+
+ Push
+
+
+
+
+
+
+
+
+
+
+ Operator Directives
+
+
+ {[
+ {
+ icon: Target,
+ label: "Audience Segmentation",
+ desc: "Filter by role",
+ },
+ {
+ icon: Calendar,
+ label: "Scheduled Dispatch",
+ desc: "Queue for later",
+ },
+ {
+ icon: History,
+ label: "Audit Integrity",
+ desc: "Full log access",
+ },
+ ].map((item, idx) => (
+
+
+
+
+
+
+ {item.label}
+
+
+ {item.desc}
+
+
+
+ ))}
+
+
+
+
+ {/* History Feed */}
+
+
+
+
+
+ Transmission Log
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+
+
+
+ {isLoading ? (
+ Array.from({ length: 4 }).map((_, i) => (
+
+ ))
+ ) : filteredNotifications.length ? (
+ filteredNotifications.map((n) => (
+
+
+
+
+
+
+
+
+
+
+ {n.title}
+
+
+ Push
+
+
+
+ {n.body}
+
+
+
+
+
+ {format(
+ new Date(n.createdAt),
+ "HH:mm · MMM d, yyyy",
+ )}
+
+
+ {n.isSent ? "Delivered" : "Queued"}
+
+
+
+
+ ))
+ ) : (
+
+
+
+
+
+ Zero Telemetry
+
+
+ No transmissions detected in the current signal
+ range.
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {/* Dispatch Dialog */}
+
- )
+ );
}
diff --git a/src/services/api/client.ts b/src/services/api/client.ts
index 5559da5..a47b7d5 100644
--- a/src/services/api/client.ts
+++ b/src/services/api/client.ts
@@ -1,87 +1,96 @@
-import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
+import axios, {
+ type AxiosInstance,
+ type AxiosError,
+ type InternalAxiosRequestConfig,
+} from "axios";
-const API_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
+const useDevApiProxy =
+ import.meta.env.DEV && import.meta.env.VITE_USE_API_PROXY === "true";
+
+const API_BASE_URL = useDevApiProxy
+ ? "/api"
+ : import.meta.env.VITE_BACKEND_API_URL || "http://localhost:3001";
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
- _retry?: boolean
+ _retry?: boolean;
}
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
- 'Content-Type': 'application/json',
+ "Content-Type": "application/json",
},
withCredentials: true, // Send cookies with requests
timeout: 30000, // 30 second timeout
paramsSerializer: {
serialize: (params) => {
// Custom serializer to preserve number types
- const searchParams = new URLSearchParams()
+ const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
- searchParams.append(key, String(value))
+ searchParams.append(key, String(value));
}
- })
- return searchParams.toString()
- }
- }
-})
+ });
+ return searchParams.toString();
+ },
+ },
+});
// Request interceptor - Add auth token
apiClient.interceptors.request.use(
(config) => {
// Add token from localStorage as fallback (cookies are preferred)
- const token = localStorage.getItem('access_token')
+ const token = localStorage.getItem("access_token");
if (token) {
- config.headers.Authorization = `Bearer ${token}`
+ config.headers.Authorization = `Bearer ${token}`;
}
- return config
+ return config;
},
(error) => {
- return Promise.reject(error)
- }
-)
+ return Promise.reject(error);
+ },
+);
// Response interceptor - Handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
- const originalRequest = error.config as RetryableAxiosRequestConfig
+ const originalRequest = error.config as RetryableAxiosRequestConfig;
// Handle 401 Unauthorized - Try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
- originalRequest._retry = true
+ originalRequest._retry = true;
try {
// Try to refresh token
- const refreshToken = localStorage.getItem('refresh_token')
+ const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
- { withCredentials: true }
- )
+ { withCredentials: true },
+ );
- const { accessToken } = response.data
- localStorage.setItem('access_token', accessToken)
+ const { accessToken } = response.data;
+ localStorage.setItem("access_token", accessToken);
// Retry original request with new token
- originalRequest.headers.Authorization = `Bearer ${accessToken}`
- return apiClient(originalRequest)
+ originalRequest.headers.Authorization = `Bearer ${accessToken}`;
+ return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - logout user
- localStorage.removeItem('access_token')
- localStorage.removeItem('refresh_token')
- localStorage.removeItem('user')
- window.location.href = '/login'
- return Promise.reject(refreshError)
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("refresh_token");
+ localStorage.removeItem("user");
+ window.location.href = "/login";
+ return Promise.reject(refreshError);
}
}
- return Promise.reject(error)
- }
-)
+ return Promise.reject(error);
+ },
+);
-export default apiClient
+export default apiClient;
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/dashboard.service.ts b/src/services/dashboard.service.ts
index 023b631..29ca2b3 100644
--- a/src/services/dashboard.service.ts
+++ b/src/services/dashboard.service.ts
@@ -1,21 +1,57 @@
-import apiClient from './api/client'
-import type { ActivityLog } from '@/types/activity.types'
+import apiClient from "./api/client";
+import type { ActivityLog } from "@/types/activity.types";
export interface UserDashboardStats {
- totalInvoices: number
- totalTransactions: number
- totalRevenue: number
- pendingInvoices: number
- growthPercentage?: number
- recentActivity?: ActivityLog[]
+ totalInvoices: number;
+ totalTransactions: number;
+ totalRevenue: number;
+ pendingInvoices: number;
+ growthPercentage?: number;
+ recentActivity?: ActivityLog[];
+}
+
+export interface DashboardMetrics {
+ totalInvoices: number;
+ totalRevenue: number;
+ totalPayments: number;
+ pendingInvoices: number;
+ overdueInvoices: number;
+ paidInvoices: number;
+}
+
+export interface ScannedInvoice {
+ id: string;
+ invoiceNumber: string;
+ customerName: string;
+ amount: number;
+ issueDate: string;
+ scannedData: Record
;
+}
+
+export interface SalesPurchaseComparison {
+ sales: {
+ status: string;
+ count: number;
+ total: number;
+ }[];
+ purchases: {
+ status: string;
+ count: number;
+ total: number;
+ }[];
+}
+
+export interface InvoiceStatusBreakdown {
+ status: string;
+ count: number;
}
export interface UserProfile {
- id: string
- email: string
- firstName: string
- lastName: string
- role: string
+ id: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ role: string;
}
class DashboardService {
@@ -23,37 +59,76 @@ class DashboardService {
* Get current user profile
*/
async getUserProfile(): Promise {
- const response = await apiClient.get('/user/profile')
- return response.data
+ const response = await apiClient.get("/user/profile");
+ return response.data;
}
/**
* Get user dashboard statistics
*/
async getUserStats(): Promise {
- const response = await apiClient.get('/user/stats')
- return response.data
+ const response = await apiClient.get("/user/stats");
+ return response.data;
}
/**
* Get user recent activity
*/
async getRecentActivity(limit: number = 10): Promise {
- const response = await apiClient.get('/user/activity', {
+ const response = await apiClient.get("/user/activity", {
params: { limit },
- })
- return response.data
+ });
+ return response.data;
}
/**
* Export user dashboard data
*/
async exportData(): Promise {
- const response = await apiClient.get('/user/export', {
- responseType: 'blob',
- })
- return response.data
+ const response = await apiClient.get("/user/export", {
+ responseType: "blob",
+ });
+ return response.data;
+ }
+
+ /**
+ * Get main dashboard metrics
+ */
+ async getMetrics(): Promise {
+ const response =
+ await apiClient.get("/dashboard/metrics");
+ return response.data;
+ }
+
+ /**
+ * Get scanned invoices pending verification
+ */
+ async getScannedInvoices(): Promise {
+ const response = await apiClient.get(
+ "/dashboard/scanned-invoices",
+ );
+ return response.data;
+ }
+
+ /**
+ * Get sales vs purchase comparison
+ */
+ async getSalesPurchaseComparison(): Promise {
+ const response = await apiClient.get(
+ "/dashboard/sales-purchase",
+ );
+ return response.data;
+ }
+
+ /**
+ * Get invoice status breakdown
+ */
+ async getInvoiceStatusBreakdown(): Promise {
+ const response = await apiClient.get(
+ "/dashboard/invoice-status",
+ );
+ return response.data;
}
}
-export const dashboardService = new DashboardService()
+export const dashboardService = new DashboardService();
diff --git a/src/services/email.service.ts b/src/services/email.service.ts
new file mode 100644
index 0000000..1137552
--- /dev/null
+++ b/src/services/email.service.ts
@@ -0,0 +1,26 @@
+import apiClient from './api/client'
+import type {
+ EmailPreviewResult,
+ EmailTemplateListResponse,
+} from '@/types/email.types'
+
+class EmailService {
+ async listPreviewTemplates(): Promise {
+ const response = await apiClient.get('/emails/preview/templates')
+ return response.data
+ }
+
+ async getPreviewJson(templateKey: string): Promise {
+ const response = await apiClient.get(
+ `/emails/preview/${templateKey}/json`,
+ )
+ return response.data
+ }
+
+ getPreviewHtmlUrl(templateKey: string): string {
+ const baseUrl = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
+ return `${baseUrl}/emails/preview/${templateKey}`
+ }
+}
+
+export const emailService = new EmailService()
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 c6991e7..baf3d62 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -1,23 +1,92 @@
// Export all services from a single entry point
-export { authService } from './auth.service'
-export { userService } from './user.service'
-export { analyticsService } from './analytics.service'
-export { securityService } from './security.service'
-export { systemService } from './system.service'
-export { announcementService } from './announcement.service'
-export { auditService } from './audit.service'
-export { settingsService } from './settings.service'
-export { dashboardService } from './dashboard.service'
-export { notificationService } from './notification.service'
+export { authService } from "./auth.service";
+export { userService } from "./user.service";
+export { analyticsService } from "./analytics.service";
+export { securityService } from "./security.service";
+export { systemService } from "./system.service";
+export { announcementService } from "./announcement.service";
+export { auditService } from "./audit.service";
+export { settingsService } from "./settings.service";
+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 { subscriptionService } from "./subscription.service";
+export { emailService } from "./email.service";
// Export types
-export type { LoginRequest, LoginResponse } from './auth.service'
-export type { User, GetUsersParams, PaginatedResponse } from './user.service'
-export type { OverviewStats, UserGrowthData, RevenueData } from './analytics.service'
-export type { SuspiciousActivity, ActiveSession, FailedLogin, ApiKey } from './security.service'
-export type { HealthStatus, SystemInfo, MaintenanceStatus } from './system.service'
-export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } from './announcement.service'
-export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
-export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
-export type { UserDashboardStats, UserProfile } from './dashboard.service'
-export type { Notification, NotificationSettings } from './notification.service'
+export type { LoginRequest, LoginResponse } from "./auth.service";
+export type { User, GetUsersParams, PaginatedResponse } from "./user.service";
+export type {
+ OverviewStats,
+ UserGrowthData,
+ RevenueData,
+} from "./analytics.service";
+export type {
+ SuspiciousActivity,
+ ActiveSession,
+ FailedLogin,
+ ApiKey,
+} from "./security.service";
+export type {
+ HealthStatus,
+ SystemInfo,
+ MaintenanceStatus,
+} from "./system.service";
+export type {
+ Announcement,
+ CreateAnnouncementData,
+ UpdateAnnouncementData,
+} from "./announcement.service";
+export type { AuditLog, GetAuditLogsParams, AuditStats } from "./audit.service";
+export type {
+ Setting,
+ CreateSettingData,
+ UpdateSettingData,
+} from "./settings.service";
+export type { UserDashboardStats, UserProfile } from "./dashboard.service";
+export type {
+ Notification,
+ NotificationSettings,
+} from "./notification.service";
+export type {
+ Payment,
+ PaymentRequest,
+ PaymentFilters,
+ PaymentRequestFilters,
+} from "./payment.service";
+export type {
+ Invoice,
+ Proforma,
+ ProformaRequest,
+ ProformaRequestItem,
+ InvoiceFilters,
+ 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";
+export type {
+ SubscriptionPlan,
+ PlanFeatures,
+ UpdatePlanData,
+ AssignPlanData,
+ UserSubscriptionSummary,
+ SubscriptionTransaction as AdminSubscriptionTransaction,
+ GetSubscriptionTransactionsParams,
+ BillingInterval,
+ ChapaTransactionStatus,
+} from "@/types/subscription.types";
+export type {
+ EmailTemplateMeta,
+ EmailPreviewResult,
+} from "@/types/email.types";
diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts
new file mode 100644
index 0000000..6c6f7f1
--- /dev/null
+++ b/src/services/invoice.service.ts
@@ -0,0 +1,272 @@
+import apiClient from "./api/client";
+
+export interface PaginatedResponse {
+ data: T[];
+ meta: {
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+ };
+}
+
+export interface InvoiceItem {
+ id: string;
+ description: string;
+ quantity: number;
+ unitPrice: number;
+ total: number;
+}
+
+export interface Invoice {
+ id: string;
+ invoiceNumber: string;
+ customerName: string;
+ customerEmail: string;
+ customerPhone: string;
+ amount: number;
+ currency: string;
+ type: "SALES" | "PURCHASE";
+ status: "DRAFT" | "PENDING" | "PAID" | "OVERDUE" | "CANCELLED";
+ issueDate: string;
+ dueDate: string;
+ paidDate?: string;
+ description: string;
+ notes: string;
+ taxAmount: number;
+ discountAmount: number;
+ isScanned: boolean;
+ scannedData?: any;
+ userId: string;
+ items: InvoiceItem[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Proforma {
+ id: string;
+ proformaNumber: string;
+ customerName: string;
+ customerEmail: string;
+ customerPhone: string;
+ amount: number;
+ currency: string;
+ issueDate: string;
+ dueDate: string;
+ description: string;
+ notes: string;
+ taxAmount: number;
+ discountAmount: number;
+ pdfPath: string;
+ userId: string;
+ items: InvoiceItem[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ProformaRequestItem {
+ id?: string;
+ itemName: string;
+ itemDescription?: string;
+ quantity: number;
+ unitOfMeasure: string;
+ technicalSpecifications?: Record;
+ createdAt?: string;
+}
+
+export interface ProformaRequest {
+ id: string;
+ requesterId: string;
+ title: string;
+ description: string;
+ category: "EQUIPMENT" | "SERVICE" | "MIXED";
+ status:
+ | "DRAFT"
+ | "OPEN"
+ | "UNDER_REVIEW"
+ | "REVISION_REQUESTED"
+ | "CLOSED"
+ | "CANCELLED";
+ submissionDeadline: string;
+ allowRevisions: boolean;
+ paymentTerms: string;
+ incoterms?: string;
+ taxIncluded: boolean;
+ discountStructure?: string;
+ validityPeriod?: number;
+ attachments?: { name: string; url: string }[];
+ items: ProformaRequestItem[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface InvoiceFilters {
+ page?: number;
+ limit?: number;
+ status?: string;
+ type?: string;
+ startDate?: string;
+ endDate?: string;
+ customerName?: string;
+ invoiceNumber?: string;
+ search?: string;
+}
+
+export interface ProformaFilters {
+ page?: number;
+ limit?: number;
+ startDate?: string;
+ endDate?: string;
+ customerName?: string;
+ proformaNumber?: string;
+ search?: string;
+}
+
+export interface ProformaRequestFilters {
+ page?: number;
+ limit?: number;
+ status?: string;
+ category?: string;
+ search?: string;
+ deadlineFrom?: string;
+ deadlineTo?: string;
+}
+
+class InvoiceService {
+ /**
+ * Get all invoices
+ */
+ async getInvoices(
+ filters: InvoiceFilters = {},
+ ): Promise> {
+ const response = await apiClient.get>(
+ "/invoices",
+ {
+ params: filters,
+ },
+ );
+ return response.data;
+ }
+
+ /**
+ * Create a new invoice
+ */
+ async createInvoice(data: any): Promise {
+ const response = await apiClient.post("/invoices", data);
+ return response.data;
+ }
+
+ /**
+ * Update an existing invoice
+ */
+ async updateInvoice(id: string, data: any): Promise {
+ const response = await apiClient.put(`/invoices/${id}`, data);
+ return response.data;
+ }
+
+ /**
+ * Delete an invoice
+ */
+ async deleteInvoice(id: string): Promise {
+ await apiClient.delete(`/invoices/${id}`);
+ }
+
+ /**
+ * Get all proforma invoices
+ */
+ async getProformas(
+ filters: ProformaFilters = {},
+ ): Promise> {
+ const response = await apiClient.get>(
+ "/proforma",
+ {
+ params: filters,
+ },
+ );
+ return response.data;
+ }
+
+ /**
+ * Create a new proforma invoice
+ */
+ async createProforma(data: any): Promise {
+ const response = await apiClient.post("/proforma", data);
+ return response.data;
+ }
+
+ /**
+ * Update an existing proforma invoice
+ */
+ async updateProforma(id: string, data: any): Promise {
+ const response = await apiClient.put(`/proforma/${id}`, data);
+ return response.data;
+ }
+
+ /**
+ * Delete a proforma invoice
+ */
+ async deleteProforma(id: string): Promise {
+ await apiClient.delete(`/proforma/${id}`);
+ }
+
+ /**
+ * Get all proforma requests (admin view)
+ */
+ async getProformaRequests(
+ filters: ProformaRequestFilters = {},
+ ): Promise> {
+ const response = await apiClient.get>(
+ "/admin/proforma-requests",
+ {
+ params: filters,
+ },
+ );
+ return response.data;
+ }
+
+ /**
+ * Get proforma request details (admin view)
+ */
+ async getProformaRequestDetails(id: string): Promise {
+ const response = await apiClient.get(
+ `/admin/proforma-requests/${id}`,
+ );
+ return response.data;
+ }
+ /**
+ * Create a new proforma request
+ */
+ async createProformaRequest(data: any): Promise {
+ const response = await apiClient.post(
+ "/proforma-requests",
+ data,
+ );
+ return response.data;
+ }
+ /**
+ * Update an existing proforma request
+ */
+ async updateProformaRequest(id: string, data: any): Promise {
+ const response = await apiClient.put(
+ `/proforma-requests/${id}`,
+ data,
+ );
+ return response.data;
+ }
+ /**
+ * Close a proforma request
+ */
+ async closeProformaRequest(id: string): Promise {
+ await apiClient.post(`/proforma-requests/${id}/close`);
+ }
+ /**
+ * Cancel a proforma request
+ */
+ async cancelProformaRequest(id: string): Promise {
+ await apiClient.post(`/proforma-requests/${id}/cancel`);
+ }
+}
+
+export const invoiceService = new InvoiceService();
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..8864437 100644
--- a/src/services/notification.service.ts
+++ b/src/services/notification.service.ts
@@ -1,112 +1,175 @@
-import apiClient from './api/client'
+import apiClient from "./api/client";
export interface Notification {
- id: string
- title: string
- message: string
- type: 'system' | 'user' | 'alert' | 'invoice' | 'payment'
- recipient: string
- status: 'sent' | 'delivered' | 'read' | 'unread'
- isRead: boolean
- createdAt: string
- sentAt?: string
- readAt?: string
+ id: string;
+ title: string;
+ body: string;
+ icon?: string;
+ url?: string;
+ sentAt?: string;
+ scheduledFor?: string;
+ isSent: boolean;
+ recipientId: string;
+ data?: Record;
+ createdAt: string;
+ updatedAt: string;
}
export interface NotificationSettings {
- emailNotifications: boolean
- pushNotifications: boolean
- invoiceReminders: boolean
- paymentAlerts: boolean
- systemUpdates: boolean
+ emailNotifications: boolean;
+ pushNotifications: boolean;
+ invoiceReminders: boolean;
+ paymentAlerts: boolean;
+ systemUpdates: boolean;
+}
+
+export interface SendPushNotificationRequest {
+ title: string;
+ body: string;
+ icon?: string;
+ url?: string;
+ recipientId?: string;
+ scheduledFor?: string;
+ data?: Record;
+}
+
+export interface SendSmsNotificationRequest {
+ body: string;
+ recipientPhone?: string; // If null, system broadcast
+ scheduledFor?: string;
+}
+
+export interface SendEmailNotificationRequest {
+ subject: string;
+ body: string; // HTML or Plain text
+ recipientEmail?: string; // If null, system broadcast
+ scheduledFor?: string;
}
class NotificationService {
/**
- * Get all notifications for current user
+ * Get all notifications for current user (Paginated)
*/
async getNotifications(params?: {
- type?: string
- status?: string
- search?: string
+ page?: number;
+ limit?: number;
+ type?: string;
+ status?: string;
+ search?: string;
}): Promise {
- const response = await apiClient.get('/notifications', {
+ const response = await apiClient.get("/notifications", {
params,
- })
- return response.data
+ });
+ return response.data;
}
/**
* Get unread notification count
*/
async getUnreadCount(): Promise {
- const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
- return response.data.count
+ const response = await apiClient.get<{ count: number }>(
+ "/notifications/unread-count",
+ );
+ return response.data.count;
}
/**
* Mark notification as read
*/
async markAsRead(id: string): Promise {
- await apiClient.post(`/notifications/${id}/read`)
+ await apiClient.post(`/notifications/${id}/read`);
}
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise {
- await apiClient.post('/notifications/read-all')
+ await apiClient.post("/notifications/read-all");
}
/**
- * Send notification (ADMIN only)
+ * Send push notification (ADMIN only)
*/
- async sendNotification(data: {
- title: string
- message: string
- type: string
- recipient?: string
- recipientType?: 'user' | 'all'
- }): Promise {
- const response = await apiClient.post('/notifications/send', data)
- return response.data
+ async sendPushNotification(
+ data: SendPushNotificationRequest,
+ ): Promise {
+ const response = await apiClient.post(
+ "/admin/notifications/send-push",
+ data,
+ );
+ return response.data;
+ }
+
+ /**
+ * Send SMS notification (ADMIN only)
+ */
+ async sendSmsNotification(
+ data: SendSmsNotificationRequest,
+ ): Promise<{ success: boolean; messageId: string }> {
+ const response = await apiClient.post<{
+ success: boolean;
+ messageId: string;
+ }>("/admin/notifications/send-sms", data);
+ return response.data;
+ }
+
+ /**
+ * Send Email notification (ADMIN only)
+ */
+ async sendEmailNotification(
+ data: SendEmailNotificationRequest,
+ ): Promise<{ success: boolean; messageId: string }> {
+ const response = await apiClient.post<{
+ success: boolean;
+ messageId: string;
+ }>("/admin/notifications/send-email", data);
+ return response.data;
}
/**
* Subscribe to push notifications
*/
async subscribeToPush(subscription: PushSubscription): Promise {
- await apiClient.post('/notifications/subscribe', subscription)
+ await apiClient.post("/notifications/subscribe", subscription);
}
/**
* Unsubscribe from push notifications
*/
async unsubscribeFromPush(endpoint: string): Promise {
- await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
+ await apiClient.delete(
+ `/notifications/unsubscribe/${encodeURIComponent(endpoint)}`,
+ );
}
/**
* Get notification settings
*/
async getSettings(): Promise {
- const response = await apiClient.get('/notifications/settings')
- return response.data
+ const response = await apiClient.get