Compare commits
No commits in common. "el-ui" and "main" have entirely different histories.
|
|
@ -1,10 +1,6 @@
|
||||||
# Backend API Configuration
|
# Backend API Configuration
|
||||||
VITE_BACKEND_API_URL=http://localhost:3000/api/v1
|
VITE_BACKEND_API_URL=http://localhost:3000/api/v1
|
||||||
|
|
||||||
# Local dev: avoid CORS by proxying /api -> API (set VITE_USE_API_PROXY=true and restart dev server)
|
|
||||||
# VITE_USE_API_PROXY=true
|
|
||||||
# VITE_PROXY_TARGET=https://api.yaltopiaticket.com
|
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
VITE_ENV=development
|
VITE_ENV=development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
name: Deploy Yaltopia Tickets Admin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- prod
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Update admin repo on server
|
|
||||||
run: |
|
|
||||||
cd /opt/yaltopia/ticket/Yaltopia-Ticket-Admin
|
|
||||||
git fetch origin
|
|
||||||
git reset --hard origin/prod
|
|
||||||
|
|
||||||
- name: Rebuild and restart admin stack
|
|
||||||
run: |
|
|
||||||
cd /opt/yaltopia/ticket
|
|
||||||
docker compose up -d --build admin
|
|
||||||
|
|
@ -7,18 +7,11 @@ WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm ci --only=production
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build-time env (Vite inlines these at build time)
|
|
||||||
ARG VITE_BACKEND_API_URL
|
|
||||||
ARG VITE_ENV=production
|
|
||||||
|
|
||||||
ENV VITE_BACKEND_API_URL=${VITE_BACKEND_API_URL}
|
|
||||||
ENV VITE_ENV=${VITE_ENV}
|
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build:prod
|
RUN npm run build:prod
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
{
|
|
||||||
"subscriptions": {
|
|
||||||
"transactions": [
|
|
||||||
{
|
|
||||||
"id": "sub_tx_001",
|
|
||||||
"userId": "u_123",
|
|
||||||
"userName": "John Doe",
|
|
||||||
"planName": "Professional",
|
|
||||||
"amount": 29.99,
|
|
||||||
"currency": "USD",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"paymentMethod": "Credit Card (**** 4242)",
|
|
||||||
"transactionDate": "2024-04-15T10:00:00Z",
|
|
||||||
"failureReason": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sub_tx_002",
|
|
||||||
"userId": "u_456",
|
|
||||||
"userName": "Jane Smith",
|
|
||||||
"planName": "Enterprise",
|
|
||||||
"amount": 199.99,
|
|
||||||
"currency": "USD",
|
|
||||||
"status": "FAILED",
|
|
||||||
"paymentMethod": "PayPal",
|
|
||||||
"transactionDate": "2024-04-16T14:20:00Z",
|
|
||||||
"failureReason": "Insufficient funds"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"issues": [
|
|
||||||
{
|
|
||||||
"id": "iss_501",
|
|
||||||
"reporterId": "u_789",
|
|
||||||
"reporterName": "Bob Miller",
|
|
||||||
"type": "BUG",
|
|
||||||
"priority": "HIGH",
|
|
||||||
"title": "Cannot upload attachments in Proforma Requests",
|
|
||||||
"description": "Getting a 500 error when trying to upload PDF files larger than 2MB.",
|
|
||||||
"status": "OPEN",
|
|
||||||
"assignedTo": "admin_001",
|
|
||||||
"createdAt": "2024-04-16T08:30:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"faq": [
|
|
||||||
{
|
|
||||||
"id": "faq_001",
|
|
||||||
"question": "How do I reset my password?",
|
|
||||||
"answer": "Go to settings > security and click on 'Reset Password'.",
|
|
||||||
"category": "ACCOUNT",
|
|
||||||
"isPublished": true,
|
|
||||||
"order": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"tickets": [
|
|
||||||
{
|
|
||||||
"id": "sup_101",
|
|
||||||
"requesterEmail": "help@client.com",
|
|
||||||
"subject": "Missing Invoice #INV-102",
|
|
||||||
"message": "I paid for my subscription but didn't receive the invoice.",
|
|
||||||
"status": "PENDING",
|
|
||||||
"createdAt": "2024-04-16T11:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"sms": {
|
|
||||||
"to": "+1234567890",
|
|
||||||
"content": "Your payment was successful."
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"to": "user@example.com",
|
|
||||||
"subject": "Payment Confirmation",
|
|
||||||
"body": "Your payment of $29.99 has been processed."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
package-lock.json
generated
27
package-lock.json
generated
|
|
@ -24,12 +24,10 @@
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-is": "^19.2.5",
|
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
@ -4818,22 +4816,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cmdk": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
|
||||||
"@radix-ui/react-id": "^1.1.0",
|
|
||||||
"@radix-ui/react-primitive": "^2.0.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -7054,10 +7036,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.5",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,10 @@
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-is": "^19.2.5",
|
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
|
||||||
130
src/App.tsx
130
src/App.tsx
|
|
@ -1,44 +1,30 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom"
|
||||||
import { AppShell } from "@/layouts/app-shell";
|
import { AppShell } from "@/layouts/app-shell"
|
||||||
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
import { ProtectedRoute } from "@/components/ProtectedRoute"
|
||||||
import LoginPage from "@/pages/login";
|
import LoginPage from "@/pages/login"
|
||||||
import DashboardPage from "@/pages/admin/dashboard";
|
import DashboardPage from "@/pages/admin/dashboard"
|
||||||
import UsersPage from "@/pages/admin/users";
|
import UsersPage from "@/pages/admin/users"
|
||||||
import UserDetailsPage from "@/pages/admin/users/[id]";
|
import UserDetailsPage from "@/pages/admin/users/[id]"
|
||||||
import UserActivityPage from "@/pages/admin/users/[id]/activity";
|
import UserActivityPage from "@/pages/admin/users/[id]/activity"
|
||||||
import ActivityLogPage from "@/pages/activity-log";
|
import ActivityLogPage from "@/pages/activity-log"
|
||||||
import SettingsPage from "@/pages/admin/settings";
|
import SettingsPage from "@/pages/admin/settings"
|
||||||
import MaintenancePage from "@/pages/admin/maintenance";
|
import MaintenancePage from "@/pages/admin/maintenance"
|
||||||
import AnnouncementsPage from "@/pages/admin/announcements";
|
import AnnouncementsPage from "@/pages/admin/announcements"
|
||||||
import AuditPage from "@/pages/admin/audit";
|
import AuditPage from "@/pages/admin/audit"
|
||||||
import SecurityPage from "@/pages/admin/security";
|
import SecurityPage from "@/pages/admin/security"
|
||||||
import FailedLoginsPage from "@/pages/admin/security/failed-logins";
|
import FailedLoginsPage from "@/pages/admin/security/failed-logins"
|
||||||
import SuspiciousActivityPage from "@/pages/admin/security/suspicious";
|
import SuspiciousActivityPage from "@/pages/admin/security/suspicious"
|
||||||
import ApiKeysPage from "@/pages/admin/security/api-keys";
|
import ApiKeysPage from "@/pages/admin/security/api-keys"
|
||||||
import RateLimitsPage from "@/pages/admin/security/rate-limits";
|
import RateLimitsPage from "@/pages/admin/security/rate-limits"
|
||||||
import SessionsPage from "@/pages/admin/security/sessions";
|
import SessionsPage from "@/pages/admin/security/sessions"
|
||||||
import AnalyticsPage from "@/pages/admin/analytics";
|
import AnalyticsPage from "@/pages/admin/analytics"
|
||||||
import AnalyticsOverviewPage from "@/pages/admin/analytics/overview";
|
import AnalyticsOverviewPage from "@/pages/admin/analytics/overview"
|
||||||
import AnalyticsUsersPage from "@/pages/admin/analytics/users";
|
import AnalyticsUsersPage from "@/pages/admin/analytics/users"
|
||||||
import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue";
|
import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue"
|
||||||
import AnalyticsStoragePage from "@/pages/admin/analytics/storage";
|
import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
|
||||||
import AnalyticsApiPage from "@/pages/admin/analytics/api";
|
import AnalyticsApiPage from "@/pages/admin/analytics/api"
|
||||||
import HealthPage from "@/pages/admin/health";
|
import HealthPage from "@/pages/admin/health"
|
||||||
import NotificationsPage from "@/pages/notifications";
|
import NotificationsPage from "@/pages/notifications"
|
||||||
import PaymentsListPage from "@/pages/admin/payments/payments-list";
|
|
||||||
import PaymentRequestsPage from "@/pages/admin/payments/payment-requests";
|
|
||||||
import InvoicesPage from "@/pages/admin/invoices/invoices-list";
|
|
||||||
import ProformaPage from "@/pages/admin/invoices/proforma-list";
|
|
||||||
import ProformaRequestsPage from "@/pages/admin/invoices/proforma-requests";
|
|
||||||
import SubscriptionTransactionsPage from "@/pages/admin/transactions/subscription-transactions";
|
|
||||||
import SystemMembersPage from "@/pages/admin/system-members";
|
|
||||||
import IssuesPage from "@/pages/admin/issues";
|
|
||||||
import FaqSupportPage from "@/pages/admin/support/faq";
|
|
||||||
import NotificationBroadcastPage from "@/pages/admin/notifications/broadcast";
|
|
||||||
import EmailTemplatesPage from "@/pages/admin/email-templates";
|
|
||||||
import EmailTemplatePreviewPage from "@/pages/admin/email-templates/[key]";
|
|
||||||
import SubscriptionsAdminPage from "@/pages/admin/subscriptions";
|
|
||||||
import PlanManagementPage from "@/pages/admin/subscriptions/plans/[id]";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,71 +51,23 @@ function App() {
|
||||||
<Route path="admin/announcements" element={<AnnouncementsPage />} />
|
<Route path="admin/announcements" element={<AnnouncementsPage />} />
|
||||||
<Route path="admin/audit" element={<AuditPage />} />
|
<Route path="admin/audit" element={<AuditPage />} />
|
||||||
<Route path="admin/security" element={<SecurityPage />} />
|
<Route path="admin/security" element={<SecurityPage />} />
|
||||||
<Route
|
<Route path="admin/security/failed-logins" element={<FailedLoginsPage />} />
|
||||||
path="admin/security/failed-logins"
|
<Route path="admin/security/suspicious" element={<SuspiciousActivityPage />} />
|
||||||
element={<FailedLoginsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/security/suspicious"
|
|
||||||
element={<SuspiciousActivityPage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/security/api-keys" element={<ApiKeysPage />} />
|
<Route path="admin/security/api-keys" element={<ApiKeysPage />} />
|
||||||
<Route path="admin/security/rate-limits" element={<RateLimitsPage />} />
|
<Route path="admin/security/rate-limits" element={<RateLimitsPage />} />
|
||||||
<Route path="admin/security/sessions" element={<SessionsPage />} />
|
<Route path="admin/security/sessions" element={<SessionsPage />} />
|
||||||
<Route path="admin/analytics" element={<AnalyticsPage />} />
|
<Route path="admin/analytics" element={<AnalyticsPage />} />
|
||||||
<Route
|
<Route path="admin/analytics/overview" element={<AnalyticsOverviewPage />} />
|
||||||
path="admin/analytics/overview"
|
|
||||||
element={<AnalyticsOverviewPage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/analytics/users" element={<AnalyticsUsersPage />} />
|
<Route path="admin/analytics/users" element={<AnalyticsUsersPage />} />
|
||||||
<Route
|
<Route path="admin/analytics/revenue" element={<AnalyticsRevenuePage />} />
|
||||||
path="admin/analytics/revenue"
|
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
||||||
element={<AnalyticsRevenuePage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/analytics/storage"
|
|
||||||
element={<AnalyticsStoragePage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
||||||
<Route path="admin/payments" element={<PaymentsListPage />} />
|
|
||||||
<Route
|
|
||||||
path="admin/payment-requests"
|
|
||||||
element={<PaymentRequestsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/transactions/subscriptions"
|
|
||||||
element={<SubscriptionTransactionsPage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/system-members" element={<SystemMembersPage />} />
|
|
||||||
<Route path="admin/issues" element={<IssuesPage />} />
|
|
||||||
<Route path="admin/support/faq" element={<FaqSupportPage />} />
|
|
||||||
<Route
|
|
||||||
path="admin/notifications/broadcast"
|
|
||||||
element={<NotificationBroadcastPage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/invoices" element={<InvoicesPage />} />
|
|
||||||
<Route path="admin/proforma" element={<ProformaPage />} />
|
|
||||||
<Route
|
|
||||||
path="admin/proforma-requests"
|
|
||||||
element={<ProformaRequestsPage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/health" element={<HealthPage />} />
|
<Route path="admin/health" element={<HealthPage />} />
|
||||||
<Route path="admin/email-templates" element={<EmailTemplatesPage />} />
|
|
||||||
<Route
|
|
||||||
path="admin/email-templates/:key"
|
|
||||||
element={<EmailTemplatePreviewPage />}
|
|
||||||
/>
|
|
||||||
<Route path="admin/subscriptions" element={<SubscriptionsAdminPage />} />
|
|
||||||
<Route
|
|
||||||
path="admin/subscriptions/plans/:id"
|
|
||||||
element={<PlanManagementPage />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom"
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
// const location = useLocation()
|
const location = useLocation()
|
||||||
// const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
// if (!token) {
|
if (!token) {
|
||||||
// // Redirect to login page with return URL
|
// Redirect to login page with return URL
|
||||||
// return <Navigate to="/login" state={{ from: location }} replace />
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
// }
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { Fragment, useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
CommandDialog,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
CommandSeparator,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import { DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
ADMIN_SEARCH_ROUTES,
|
|
||||||
groupAdminSearchRoutes,
|
|
||||||
} from "@/config/admin-search-routes";
|
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
|
||||||
|
|
||||||
export function AdminQuickSearch() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { canAccessSystemMembers, canSendBroadcast } = useAdminRole();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen((o) => !o);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchRoutes = ADMIN_SEARCH_ROUTES.filter((r) => {
|
|
||||||
if (r.path === "/admin/system-members" && !canAccessSystemMembers)
|
|
||||||
return false;
|
|
||||||
if (r.path === "/admin/notifications/broadcast" && !canSendBroadcast)
|
|
||||||
return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const grouped = groupAdminSearchRoutes(searchRoutes);
|
|
||||||
const groups = [...grouped.keys()];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="relative h-9 w-64 justify-start text-muted-foreground sm:pr-12"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
title="Search and jump to a page (Ctrl+K or ⌘K)"
|
|
||||||
aria-label="Open quick search to go to any page"
|
|
||||||
>
|
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">Quick search pages…</span>
|
|
||||||
<kbd className="pointer-events-none absolute right-1.5 top-1/2 hidden h-6 -translate-y-1/2 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
|
||||||
<span className="text-xs">⌘</span>K
|
|
||||||
</kbd>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTitle className="sr-only">Go to page</DialogTitle>
|
|
||||||
<CommandInput placeholder="Type a page name or what you want to do…" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No page matched. Try another word.</CommandEmpty>
|
|
||||||
{groups.map((group, i) => (
|
|
||||||
<Fragment key={group}>
|
|
||||||
{i > 0 ? <CommandSeparator className="my-1" /> : null}
|
|
||||||
<CommandGroup heading={group}>
|
|
||||||
{grouped.get(group)?.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={item.id}
|
|
||||||
value={`${item.title} ${item.description} ${item.path} ${item.group}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(item.path);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="font-medium leading-tight">
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
<span className="line-clamp-2 text-xs leading-snug text-muted-foreground">
|
|
||||||
{item.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</CommandList>
|
|
||||||
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">Navigate:</span>{" "}
|
|
||||||
<kbd className="rounded border bg-muted px-1 font-mono">↑</kbd>{" "}
|
|
||||||
<kbd className="rounded border bg-muted px-1 font-mono">↓</kbd>{" "}
|
|
||||||
to move ·{" "}
|
|
||||||
<kbd className="rounded border bg-muted px-1 font-mono">↵</kbd> to
|
|
||||||
open · Esc to close
|
|
||||||
</div>
|
|
||||||
</CommandDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import type { ComponentProps } from "react"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
|
||||||
import { Search } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Command.displayName = CommandPrimitive.displayName
|
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: ComponentProps<typeof Dialog>) => {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg max-w-xl [&>button]:hidden">
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-2.5 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn("max-h-[min(420px,60vh)] overflow-y-auto overflow-x-hidden p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
|
||||||
>((props, ref) => (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
ref={ref}
|
|
||||||
className="py-6 text-center text-sm text-muted-foreground"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Group
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-start gap-3 rounded-sm px-2 py-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
CommandSeparator,
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Textarea.displayName = "Textarea";
|
|
||||||
|
|
||||||
export { Textarea };
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
import type { LucideIcon } from "lucide-react"
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Bell,
|
|
||||||
Receipt,
|
|
||||||
FileSearch,
|
|
||||||
ClipboardList,
|
|
||||||
CreditCard,
|
|
||||||
FileClock,
|
|
||||||
Users,
|
|
||||||
FileText,
|
|
||||||
Settings,
|
|
||||||
Wrench,
|
|
||||||
Megaphone,
|
|
||||||
Activity,
|
|
||||||
Shield,
|
|
||||||
BarChart3,
|
|
||||||
Heart,
|
|
||||||
AlertTriangle,
|
|
||||||
Key,
|
|
||||||
Gauge,
|
|
||||||
DollarSign,
|
|
||||||
HardDrive,
|
|
||||||
ArrowRightLeft,
|
|
||||||
UserCog,
|
|
||||||
LifeBuoy,
|
|
||||||
HelpCircle,
|
|
||||||
Send,
|
|
||||||
Mail,
|
|
||||||
Layers,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
export interface AdminSearchRoute {
|
|
||||||
id: string
|
|
||||||
path: string
|
|
||||||
title: string
|
|
||||||
/** Sub-heading style line shown under the title */
|
|
||||||
description: string
|
|
||||||
group: string
|
|
||||||
icon: LucideIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
|
||||||
{
|
|
||||||
id: "dashboard",
|
|
||||||
path: "/admin/dashboard",
|
|
||||||
title: "Dashboard",
|
|
||||||
description:
|
|
||||||
"High-level metrics, recent invoices, and platform status at a glance.",
|
|
||||||
group: "Overview",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notifications",
|
|
||||||
path: "/notifications",
|
|
||||||
title: "Notifications",
|
|
||||||
description:
|
|
||||||
"System alerts and messages for your account; mark read or filter.",
|
|
||||||
group: "Overview",
|
|
||||||
icon: Bell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "invoices",
|
|
||||||
path: "/admin/invoices",
|
|
||||||
title: "Invoices",
|
|
||||||
description: "Browse, search, and manage issued invoices.",
|
|
||||||
group: "Commerce",
|
|
||||||
icon: Receipt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "proforma",
|
|
||||||
path: "/admin/proforma",
|
|
||||||
title: "Proforma",
|
|
||||||
description: "View and manage proforma invoices and drafts.",
|
|
||||||
group: "Commerce",
|
|
||||||
icon: FileSearch,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "proforma-requests",
|
|
||||||
path: "/admin/proforma-requests",
|
|
||||||
title: "Proforma requests",
|
|
||||||
description: "Review and process incoming proforma requests.",
|
|
||||||
group: "Commerce",
|
|
||||||
icon: ClipboardList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "payments",
|
|
||||||
path: "/admin/payments",
|
|
||||||
title: "Payments",
|
|
||||||
description: "Recorded payments and transaction history.",
|
|
||||||
group: "Commerce",
|
|
||||||
icon: CreditCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "payment-requests",
|
|
||||||
path: "/admin/payment-requests",
|
|
||||||
title: "Payment requests",
|
|
||||||
description: "Pending and processed payment requests.",
|
|
||||||
group: "Commerce",
|
|
||||||
icon: FileClock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "subscription-txns",
|
|
||||||
path: "/admin/transactions/subscriptions",
|
|
||||||
title: "Subscription transactions",
|
|
||||||
description:
|
|
||||||
"Successful and failed subscription charges for the platform.",
|
|
||||||
group: "Subscriptions",
|
|
||||||
icon: ArrowRightLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "subscription-plans",
|
|
||||||
path: "/admin/subscriptions",
|
|
||||||
title: "Subscription plans",
|
|
||||||
description:
|
|
||||||
"Manage plan pricing, feature flags, limits, and activation.",
|
|
||||||
group: "Subscriptions",
|
|
||||||
icon: Layers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "email-templates",
|
|
||||||
path: "/admin/email-templates",
|
|
||||||
title: "Email templates",
|
|
||||||
description:
|
|
||||||
"Preview transactional email templates with sample data.",
|
|
||||||
group: "Communications",
|
|
||||||
icon: Mail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "users",
|
|
||||||
path: "/admin/users",
|
|
||||||
title: "Users",
|
|
||||||
description: "Search, create, import, and manage user accounts.",
|
|
||||||
group: "People & activity",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "system-members",
|
|
||||||
path: "/admin/system-members",
|
|
||||||
title: "System users",
|
|
||||||
description:
|
|
||||||
"Internal panel accounts (system admin, admin, customer support).",
|
|
||||||
group: "People & activity",
|
|
||||||
icon: UserCog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "logs",
|
|
||||||
path: "/admin/logs",
|
|
||||||
title: "Activity log",
|
|
||||||
description: "User activity and system events across the platform.",
|
|
||||||
group: "People & activity",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "settings",
|
|
||||||
path: "/admin/settings",
|
|
||||||
title: "Settings",
|
|
||||||
description: "System configuration, keys, and admin preferences.",
|
|
||||||
group: "Operations",
|
|
||||||
icon: Settings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "issues",
|
|
||||||
path: "/admin/issues",
|
|
||||||
title: "Issues",
|
|
||||||
description:
|
|
||||||
"Support tickets reported by customers or internal system users.",
|
|
||||||
group: "Support",
|
|
||||||
icon: LifeBuoy,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "faq-support",
|
|
||||||
path: "/admin/support/faq",
|
|
||||||
title: "FAQ & support",
|
|
||||||
description:
|
|
||||||
"Published Q&A for end users and staff; editors manage entries.",
|
|
||||||
group: "Support",
|
|
||||||
icon: HelpCircle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maintenance",
|
|
||||||
path: "/admin/maintenance",
|
|
||||||
title: "Maintenance",
|
|
||||||
description: "Enable maintenance mode and set user-facing messages.",
|
|
||||||
group: "Operations",
|
|
||||||
icon: Wrench,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "announcements",
|
|
||||||
path: "/admin/announcements",
|
|
||||||
title: "Announcements",
|
|
||||||
description: "Create and broadcast announcements to users.",
|
|
||||||
group: "Operations",
|
|
||||||
icon: Megaphone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audit",
|
|
||||||
path: "/admin/audit",
|
|
||||||
title: "Audit",
|
|
||||||
description: "Immutable audit trail of sensitive admin actions.",
|
|
||||||
group: "Operations",
|
|
||||||
icon: Activity,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "security-hub",
|
|
||||||
path: "/admin/security",
|
|
||||||
title: "Security",
|
|
||||||
description: "Hub for sessions, API keys, failed logins, and limits.",
|
|
||||||
group: "Security",
|
|
||||||
icon: Shield,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "failed-logins",
|
|
||||||
path: "/admin/security/failed-logins",
|
|
||||||
title: "Failed logins",
|
|
||||||
description: "View and manage failed login attempts.",
|
|
||||||
group: "Security",
|
|
||||||
icon: AlertTriangle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "suspicious",
|
|
||||||
path: "/admin/security/suspicious",
|
|
||||||
title: "Suspicious activity",
|
|
||||||
description: "Monitor suspicious IPs and emails.",
|
|
||||||
group: "Security",
|
|
||||||
icon: Shield,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-keys",
|
|
||||||
path: "/admin/security/api-keys",
|
|
||||||
title: "API keys",
|
|
||||||
description: "Manage API keys and tokens.",
|
|
||||||
group: "Security",
|
|
||||||
icon: Key,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rate-limits",
|
|
||||||
path: "/admin/security/rate-limits",
|
|
||||||
title: "Rate limits",
|
|
||||||
description: "View rate limit violations and abuse patterns.",
|
|
||||||
group: "Security",
|
|
||||||
icon: Gauge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sessions",
|
|
||||||
path: "/admin/security/sessions",
|
|
||||||
title: "Active sessions",
|
|
||||||
description: "Manage active user sessions across devices.",
|
|
||||||
group: "Security",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-hub",
|
|
||||||
path: "/admin/analytics",
|
|
||||||
title: "Analytics",
|
|
||||||
description: "Hub for charts, growth, revenue, and API usage.",
|
|
||||||
group: "Analytics",
|
|
||||||
icon: BarChart3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-overview",
|
|
||||||
path: "/admin/analytics/overview",
|
|
||||||
title: "Performance overview",
|
|
||||||
description: "Platform analytics overview and KPIs.",
|
|
||||||
group: "Analytics",
|
|
||||||
icon: BarChart3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-users",
|
|
||||||
path: "/admin/analytics/users",
|
|
||||||
title: "User dynamics",
|
|
||||||
description: "User growth and statistics over time.",
|
|
||||||
group: "Analytics",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-revenue",
|
|
||||||
path: "/admin/analytics/revenue",
|
|
||||||
title: "Revenue streams",
|
|
||||||
description: "Revenue trends and breakdowns.",
|
|
||||||
group: "Analytics",
|
|
||||||
icon: DollarSign,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-storage",
|
|
||||||
path: "/admin/analytics/storage",
|
|
||||||
title: "Resource allocation",
|
|
||||||
description: "Storage usage and breakdown by user or resource.",
|
|
||||||
group: "Analytics",
|
|
||||||
icon: HardDrive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-api",
|
|
||||||
path: "/admin/analytics/api",
|
|
||||||
title: "API operations",
|
|
||||||
description: "API endpoint usage and traffic statistics.",
|
|
||||||
group: "Analytics",
|
|
||||||
icon: Activity,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "health",
|
|
||||||
path: "/admin/health",
|
|
||||||
title: "System health",
|
|
||||||
description: "Service health checks, version, and diagnostics.",
|
|
||||||
group: "Operations",
|
|
||||||
icon: Heart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "broadcast",
|
|
||||||
path: "/admin/notifications/broadcast",
|
|
||||||
title: "Send notification",
|
|
||||||
description:
|
|
||||||
"Broadcast push, SMS, and email to selected audiences (admins).",
|
|
||||||
group: "Communications",
|
|
||||||
icon: Send,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const GROUP_ORDER = [
|
|
||||||
"Overview",
|
|
||||||
"Commerce",
|
|
||||||
"Subscriptions",
|
|
||||||
"People & activity",
|
|
||||||
"Support",
|
|
||||||
"Operations",
|
|
||||||
"Communications",
|
|
||||||
"Security",
|
|
||||||
"Analytics",
|
|
||||||
]
|
|
||||||
|
|
||||||
export function groupAdminSearchRoutes(
|
|
||||||
routes: AdminSearchRoute[],
|
|
||||||
): Map<string, AdminSearchRoute[]> {
|
|
||||||
const map = new Map<string, AdminSearchRoute[]>()
|
|
||||||
for (const r of routes) {
|
|
||||||
const list = map.get(r.group) ?? []
|
|
||||||
list.push(r)
|
|
||||||
map.set(r.group, list)
|
|
||||||
}
|
|
||||||
const ordered = new Map<string, AdminSearchRoute[]>()
|
|
||||||
for (const g of GROUP_ORDER) {
|
|
||||||
const list = map.get(g)
|
|
||||||
if (list?.length) ordered.set(g, list)
|
|
||||||
}
|
|
||||||
for (const [g, list] of map) {
|
|
||||||
if (!ordered.has(g)) ordered.set(g, list)
|
|
||||||
}
|
|
||||||
return ordered
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { useMemo } from "react";
|
|
||||||
import { authService } from "@/services";
|
|
||||||
import { getPermissions, hasPanelAccess, roleLabel } from "@/lib/admin-roles";
|
|
||||||
|
|
||||||
export function useAdminRole() {
|
|
||||||
const user = authService.getCurrentUser() as {
|
|
||||||
role?: string;
|
|
||||||
email?: string;
|
|
||||||
} | null;
|
|
||||||
const role = user?.role;
|
|
||||||
|
|
||||||
const permissions = useMemo(() => getPermissions(role), [role]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
role,
|
|
||||||
roleLabel: roleLabel(role),
|
|
||||||
hasPanelAccess: hasPanelAccess(role),
|
|
||||||
...permissions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, type ComponentType, useEffect } from "react";
|
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"
|
||||||
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
import { useState } from "react"
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -11,25 +11,13 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Activity,
|
Activity,
|
||||||
Heart,
|
Heart,
|
||||||
|
Search,
|
||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
CreditCard,
|
} from "lucide-react"
|
||||||
Receipt,
|
import { Button } from "@/components/ui/button"
|
||||||
FileSearch,
|
import { Input } from "@/components/ui/input"
|
||||||
ArrowRightLeft,
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
UserCog,
|
|
||||||
LifeBuoy,
|
|
||||||
HelpCircle,
|
|
||||||
Send,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Folder,
|
|
||||||
Mail,
|
|
||||||
Layers,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -37,314 +25,104 @@ import {
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import { roleLabel, getPermissions } from "@/lib/admin-roles";
|
import { authService } from "@/services"
|
||||||
import { authService } from "@/services";
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
email: string;
|
email: string
|
||||||
firstName?: string;
|
firstName?: string
|
||||||
lastName?: string;
|
lastName?: string
|
||||||
role: string;
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavItem = {
|
const adminNavigationItems = [
|
||||||
icon?: ComponentType<{ className?: string }>;
|
|
||||||
label: string;
|
|
||||||
path?: string;
|
|
||||||
children?: NavItem[];
|
|
||||||
/** Omit = visible to all panel roles */
|
|
||||||
visible?: (role: string | undefined) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function navItemIsActive(
|
|
||||||
item: NavItem,
|
|
||||||
isActive: (path?: string) => boolean,
|
|
||||||
): boolean {
|
|
||||||
if (item.path && isActive(item.path)) return true;
|
|
||||||
return item.children?.some((child) => navItemIsActive(child, isActive)) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterNavItems(
|
|
||||||
items: NavItem[],
|
|
||||||
role: string | undefined,
|
|
||||||
): NavItem[] {
|
|
||||||
return items
|
|
||||||
.filter((item) => (item.visible ? item.visible(role) : true))
|
|
||||||
.map((item) =>
|
|
||||||
item.children
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
children: filterNavItems(item.children, role),
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
)
|
|
||||||
.filter((item) => !item.children || item.children.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminNavigationItems: NavItem[] = [
|
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||||
{
|
{ icon: Users, label: "Users", path: "/admin/users" },
|
||||||
icon: Folder,
|
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
||||||
label: "Documents",
|
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
||||||
children: [
|
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
||||||
{
|
|
||||||
label: "Invoices",
|
|
||||||
path: "/admin/invoices",
|
|
||||||
icon: Receipt,
|
|
||||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Proforma",
|
|
||||||
icon: FileSearch,
|
|
||||||
children: [
|
|
||||||
{ label: "Records", path: "/admin/proforma" },
|
|
||||||
{ label: "Requests", path: "/admin/proforma-requests" },
|
|
||||||
],
|
|
||||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Payments",
|
|
||||||
icon: CreditCard,
|
|
||||||
children: [
|
|
||||||
{ label: "Records", path: "/admin/payments" },
|
|
||||||
{ label: "Requests", path: "/admin/payment-requests" },
|
|
||||||
],
|
|
||||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ArrowRightLeft,
|
|
||||||
label: "Subscriptions",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Transactions",
|
|
||||||
path: "/admin/transactions/subscriptions",
|
|
||||||
icon: ArrowRightLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Plans",
|
|
||||||
path: "/admin/subscriptions",
|
|
||||||
icon: Layers,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
label: "Users",
|
|
||||||
path: "/admin/users",
|
|
||||||
visible: (role) => getPermissions(role).canViewUsers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UserCog,
|
|
||||||
label: "System users",
|
|
||||||
path: "/admin/system-members",
|
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: FileText,
|
|
||||||
label: "Logs",
|
|
||||||
path: "/admin/logs",
|
|
||||||
visible: (role) => getPermissions(role).canViewSystemData,
|
|
||||||
},
|
|
||||||
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
|
|
||||||
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
label: "Settings",
|
|
||||||
path: "/admin/settings",
|
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Wrench,
|
|
||||||
label: "Maintenance",
|
|
||||||
path: "/admin/maintenance",
|
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
|
||||||
},
|
|
||||||
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
||||||
{
|
{ icon: Activity, label: "Audit", path: "/admin/audit" },
|
||||||
icon: Activity,
|
{ icon: Shield, label: "Security", path: "/admin/security" },
|
||||||
label: "Audit",
|
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
|
||||||
path: "/admin/audit",
|
{ icon: Heart, label: "System Health", path: "/admin/health" },
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
label: "Security",
|
|
||||||
path: "/admin/security",
|
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: BarChart3,
|
|
||||||
label: "Analytics",
|
|
||||||
path: "/admin/analytics",
|
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Heart,
|
|
||||||
label: "System Health",
|
|
||||||
path: "/admin/health",
|
|
||||||
visible: (role) => getPermissions(role).canManageSystem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Send,
|
|
||||||
label: "Communications",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Send notification",
|
|
||||||
path: "/admin/notifications/broadcast",
|
|
||||||
icon: Send,
|
|
||||||
visible: (role) => getPermissions(role).canSendNotifications,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Email templates",
|
|
||||||
path: "/admin/email-templates",
|
|
||||||
icon: Mail,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SidebarNavItem = ({
|
|
||||||
item,
|
|
||||||
depth = 0,
|
|
||||||
isActive,
|
|
||||||
role,
|
|
||||||
}: {
|
|
||||||
item: NavItem;
|
|
||||||
depth?: number;
|
|
||||||
isActive: (path?: string) => boolean;
|
|
||||||
role: string | undefined;
|
|
||||||
}) => {
|
|
||||||
const visibleChildren = item.children
|
|
||||||
? filterNavItems(item.children, role)
|
|
||||||
: undefined;
|
|
||||||
const hasChildren = visibleChildren && visibleChildren.length > 0;
|
|
||||||
const isCurrentlyActive = navItemIsActive(item, isActive);
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
|
|
||||||
|
|
||||||
// Keep open if it becomes active from external navigation (e.g. breadcrumbs or search)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isCurrentlyActive) {
|
|
||||||
setIsOpen(true);
|
|
||||||
}
|
|
||||||
}, [isCurrentlyActive]);
|
|
||||||
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-foreground",
|
|
||||||
isCurrentlyActive
|
|
||||||
? "text-primary font-semibold"
|
|
||||||
: "text-foreground/70",
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${depth * 1 + 0.75}rem` }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{Icon && <Icon className="w-5 h-5" />}
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<div className="space-y-1 ml-4 border-l border-slate-200">
|
|
||||||
{visibleChildren?.map((child) => (
|
|
||||||
<SidebarNavItem
|
|
||||||
key={child.label}
|
|
||||||
item={child}
|
|
||||||
depth={depth + 1}
|
|
||||||
isActive={isActive}
|
|
||||||
role={role}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={item.path || "#"}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
|
||||||
isActive(item.path)
|
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
|
||||||
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${depth * 1 + 0.75}rem` }}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-5 h-5" />}
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const location = useLocation();
|
const location = useLocation()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
// Initialize user from localStorage
|
// Initialize user from localStorage
|
||||||
const [user] = useState<User | null>(() => {
|
const [user] = useState<User | null>(() => {
|
||||||
const userStr = localStorage.getItem("user");
|
const userStr = localStorage.getItem('user')
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(userStr);
|
return JSON.parse(userStr)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse user data:", error);
|
console.error('Failed to parse user data:', error)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
});
|
})
|
||||||
|
|
||||||
const isActive = (path?: string) => {
|
const isActive = (path: string) => {
|
||||||
if (!path) return false;
|
return location.pathname.startsWith(path)
|
||||||
return location.pathname.startsWith(path);
|
}
|
||||||
};
|
|
||||||
|
const getPageTitle = () => {
|
||||||
|
const currentPath = location.pathname
|
||||||
|
const item = adminNavigationItems.find((item) =>
|
||||||
|
currentPath.startsWith(item.path)
|
||||||
|
)
|
||||||
|
return item?.label || "Admin Panel"
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authService.logout();
|
await authService.logout()
|
||||||
navigate("/login", { replace: true });
|
navigate('/login', { replace: true })
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const currentPath = location.pathname
|
||||||
|
navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`)
|
||||||
|
toast.success(`Searching for: ${searchQuery}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleNotificationClick = () => {
|
const handleNotificationClick = () => {
|
||||||
navigate("/notifications");
|
navigate('/notifications')
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleProfileClick = () => {
|
const handleProfileClick = () => {
|
||||||
navigate("/admin/settings");
|
navigate('/admin/settings')
|
||||||
};
|
}
|
||||||
|
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
if (user?.firstName && user?.lastName) {
|
if (user?.firstName && user?.lastName) {
|
||||||
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
|
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
|
||||||
}
|
}
|
||||||
if (user?.email) {
|
if (user?.email) {
|
||||||
return user.email.substring(0, 2).toUpperCase();
|
return user.email.substring(0, 2).toUpperCase()
|
||||||
}
|
}
|
||||||
return "AD";
|
return 'AD'
|
||||||
};
|
}
|
||||||
|
|
||||||
const getUserDisplayName = () => {
|
const getUserDisplayName = () => {
|
||||||
if (user?.firstName && user?.lastName) {
|
if (user?.firstName && user?.lastName) {
|
||||||
return `${user.firstName} ${user.lastName}`;
|
return `${user.firstName} ${user.lastName}`
|
||||||
}
|
}
|
||||||
return user?.email || "Admin User";
|
return user?.email || 'Admin User'
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
|
|
@ -355,21 +133,29 @@ export function AppShell() {
|
||||||
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
||||||
<span className="text-primary-foreground font-bold text-lg">A</span>
|
<span className="text-primary-foreground font-bold text-lg">A</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground font-semibold text-lg">
|
<span className="text-foreground font-semibold text-lg">Admin Panel</span>
|
||||||
Admin Panel
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||||
{filterNavItems(adminNavigationItems, user?.role).map((item) => (
|
{adminNavigationItems.map((item) => {
|
||||||
<SidebarNavItem
|
const Icon = item.icon
|
||||||
key={item.label}
|
return (
|
||||||
item={item}
|
<Link
|
||||||
isActive={isActive}
|
key={item.path}
|
||||||
role={user?.role}
|
to={item.path}
|
||||||
/>
|
className={cn(
|
||||||
))}
|
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isActive(item.path)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-foreground/70 hover:bg-accent hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
|
|
@ -379,17 +165,8 @@ export function AppShell() {
|
||||||
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">
|
<p className="text-sm font-medium truncate">{getUserDisplayName()}</p>
|
||||||
{getUserDisplayName()}
|
<p className="text-xs text-muted-foreground truncate">{user?.email || 'admin@example.com'}</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{user?.email || "admin@example.com"}
|
|
||||||
</p>
|
|
||||||
{user?.role && (
|
|
||||||
<p className="text-[10px] font-semibold text-primary/80 uppercase tracking-wider mt-0.5">
|
|
||||||
{roleLabel(user.role)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -408,15 +185,18 @@ export function AppShell() {
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
||||||
<div className="flex-1" />
|
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<AdminQuickSearch />
|
<form onSubmit={handleSearch} className="relative">
|
||||||
<Button
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
variant="ghost"
|
<Input
|
||||||
size="icon"
|
placeholder="Quick Search..."
|
||||||
className="relative"
|
className="pl-10 w-64"
|
||||||
onClick={handleNotificationClick}
|
value={searchQuery}
|
||||||
>
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<Button variant="ghost" size="icon" className="relative" onClick={handleNotificationClick}>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -431,12 +211,8 @@ export function AppShell() {
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">{getUserDisplayName()}</p>
|
||||||
{getUserDisplayName()}
|
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{user?.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
@ -444,7 +220,7 @@ export function AppShell() {
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Profile Settings
|
Profile Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigate("/notifications")}>
|
<DropdownMenuItem onClick={() => navigate('/notifications')}>
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
Notifications
|
Notifications
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -464,5 +240,5 @@ export function AppShell() {
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* Panel roles for admin / support staff.
|
|
||||||
* Backend should return one of these on the authenticated user object.
|
|
||||||
*/
|
|
||||||
export const AdminRole = {
|
|
||||||
SUPER_ADMIN: "SUPER_ADMIN",
|
|
||||||
ADMIN: "ADMIN",
|
|
||||||
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole];
|
|
||||||
|
|
||||||
export interface RolePermissions {
|
|
||||||
canManageSystem: boolean; // Settings, Maintenance, Health, Security
|
|
||||||
canViewSystemData: boolean; // Audit logs, etc.
|
|
||||||
|
|
||||||
// App Users
|
|
||||||
canViewUsers: boolean;
|
|
||||||
canCreateUsers: boolean;
|
|
||||||
canEditUsers: boolean;
|
|
||||||
canDeleteUsers: boolean;
|
|
||||||
|
|
||||||
// Business Data (Invoices, Proforma, Payments)
|
|
||||||
canViewBusinessData: boolean;
|
|
||||||
canCreateBusinessData: boolean;
|
|
||||||
canEditBusinessData: boolean;
|
|
||||||
canDeleteBusinessData: boolean;
|
|
||||||
|
|
||||||
// Notifications
|
|
||||||
canViewNotifications: boolean;
|
|
||||||
canSendNotifications: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PERMISSIONS: Record<AdminRoleValue, RolePermissions> = {
|
|
||||||
[AdminRole.SUPER_ADMIN]: {
|
|
||||||
canManageSystem: true,
|
|
||||||
canViewSystemData: true,
|
|
||||||
canViewUsers: true,
|
|
||||||
canCreateUsers: true,
|
|
||||||
canEditUsers: true,
|
|
||||||
canDeleteUsers: true,
|
|
||||||
canViewBusinessData: true,
|
|
||||||
canCreateBusinessData: true,
|
|
||||||
canEditBusinessData: true,
|
|
||||||
canDeleteBusinessData: true,
|
|
||||||
canViewNotifications: true,
|
|
||||||
canSendNotifications: true,
|
|
||||||
},
|
|
||||||
[AdminRole.ADMIN]: {
|
|
||||||
canManageSystem: false,
|
|
||||||
canViewSystemData: true,
|
|
||||||
canViewUsers: true,
|
|
||||||
canCreateUsers: false,
|
|
||||||
canEditUsers: true,
|
|
||||||
canDeleteUsers: false,
|
|
||||||
canViewBusinessData: true,
|
|
||||||
canCreateBusinessData: false,
|
|
||||||
canEditBusinessData: true,
|
|
||||||
canDeleteBusinessData: false,
|
|
||||||
canViewNotifications: true,
|
|
||||||
canSendNotifications: true,
|
|
||||||
},
|
|
||||||
[AdminRole.CUSTOMER_SUPPORT]: {
|
|
||||||
canManageSystem: false,
|
|
||||||
canViewSystemData: true,
|
|
||||||
canViewUsers: true,
|
|
||||||
canCreateUsers: true,
|
|
||||||
canEditUsers: true,
|
|
||||||
canDeleteUsers: true,
|
|
||||||
canViewBusinessData: true,
|
|
||||||
canCreateBusinessData: false,
|
|
||||||
canEditBusinessData: false,
|
|
||||||
canDeleteBusinessData: false,
|
|
||||||
canViewNotifications: true,
|
|
||||||
canSendNotifications: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getPermissions(role: string | undefined): RolePermissions {
|
|
||||||
const r = (role as AdminRoleValue) || AdminRole.CUSTOMER_SUPPORT;
|
|
||||||
return PERMISSIONS[r] || PERMISSIONS[AdminRole.CUSTOMER_SUPPORT];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PANEL_ROLES = new Set<string>([
|
|
||||||
AdminRole.SUPER_ADMIN,
|
|
||||||
AdminRole.ADMIN,
|
|
||||||
AdminRole.CUSTOMER_SUPPORT,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function hasPanelAccess(role: string | undefined): boolean {
|
|
||||||
if (!role) return false;
|
|
||||||
return PANEL_ROLES.has(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roleLabel(role: string | undefined): string {
|
|
||||||
switch (role) {
|
|
||||||
case AdminRole.SUPER_ADMIN:
|
|
||||||
return "System Admin";
|
|
||||||
case AdminRole.ADMIN:
|
|
||||||
return "Admin";
|
|
||||||
case AdminRole.CUSTOMER_SUPPORT:
|
|
||||||
return "Customer Support";
|
|
||||||
default:
|
|
||||||
return role ?? "User";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legacy helpers maintained for compatibility but using new logic */
|
|
||||||
export function isSuperAdmin(role: string | undefined): boolean {
|
|
||||||
return role === AdminRole.SUPER_ADMIN;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canEdit(role: string | undefined): boolean {
|
|
||||||
return getPermissions(role).canEditBusinessData;
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +1,195 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Search,
|
Table,
|
||||||
ChevronLeft,
|
TableBody,
|
||||||
ChevronRight,
|
TableCell,
|
||||||
Filter,
|
TableHead,
|
||||||
Terminal,
|
TableHeader,
|
||||||
} from "lucide-react";
|
TableRow,
|
||||||
import { auditService, type AuditLog } from "@/services";
|
} from "@/components/ui/table"
|
||||||
import { format } from "date-fns";
|
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils";
|
import { auditService, type AuditLog } from "@/services"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
export default function ActivityLogPage() {
|
export default function ActivityLogPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1)
|
||||||
const [limit] = useState(15);
|
const [limit] = useState(20)
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("")
|
||||||
|
const [actionFilter, setActionFilter] = useState("")
|
||||||
|
const [resourceTypeFilter, setResourceTypeFilter] = useState("")
|
||||||
|
|
||||||
const { data: auditData, isLoading } = useQuery({
|
const { data: auditData, isLoading } = useQuery({
|
||||||
queryKey: ["activity-log", page, limit, search],
|
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: Record<string, string | number> = { page, limit };
|
const params: Record<string, string | number> = { page, limit }
|
||||||
if (search) params.search = search;
|
if (search) params.search = search
|
||||||
return await auditService.getAuditLogs(params);
|
if (actionFilter) params.action = actionFilter
|
||||||
|
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
|
||||||
|
return await auditService.getAuditLogs(params)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const getActionColor = (action: string) => {
|
const handleExport = async () => {
|
||||||
const act = action.toUpperCase();
|
try {
|
||||||
if (act.includes("CREATE"))
|
const blob = await auditService.exportAuditLogs({ format: 'csv' })
|
||||||
return "text-blue-600 bg-blue-50 border-blue-100";
|
const url = window.URL.createObjectURL(blob)
|
||||||
if (act.includes("UPDATE"))
|
const a = document.createElement('a')
|
||||||
return "text-emerald-600 bg-emerald-50 border-emerald-100";
|
a.href = url
|
||||||
if (act.includes("DELETE"))
|
a.download = `activity-log-${format(new Date(), 'yyyy-MM-dd')}.csv`
|
||||||
return "text-rose-600 bg-rose-50 border-rose-100";
|
document.body.appendChild(a)
|
||||||
if (act.includes("LOGIN"))
|
a.click()
|
||||||
return "text-purple-600 bg-purple-50 border-purple-100";
|
window.URL.revokeObjectURL(url)
|
||||||
return "text-slate-600 bg-slate-50 border-slate-100";
|
document.body.removeChild(a)
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionBadgeColor = (action: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
Create: "bg-blue-500",
|
||||||
|
Update: "bg-green-500",
|
||||||
|
Delete: "bg-red-500",
|
||||||
|
Login: "bg-purple-500",
|
||||||
|
Logout: "bg-gray-500",
|
||||||
|
}
|
||||||
|
return colors[action] || "bg-gray-500"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<h2 className="text-3xl font-bold">Activity Log</h2>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<Button onClick={handleExport}>
|
||||||
Activity Log
|
<Download className="w-4 h-4 mr-2" />
|
||||||
</h1>
|
Export Log
|
||||||
<p className="text-gray-500 mt-1">
|
</Button>
|
||||||
Audit trail of all administrative actions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* View only access: Export button removed */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
System Audit
|
<CardTitle>All Activities</CardTitle>
|
||||||
</CardTitle>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative w-64">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Input
|
||||||
<Input
|
placeholder="Search activity..."
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
className="pl-10 w-64"
|
||||||
placeholder="Search activity or user..."
|
value={search}
|
||||||
value={search}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
<option value="Create">Create</option>
|
||||||
|
<option value="Update">Update</option>
|
||||||
|
<option value="Delete">Delete</option>
|
||||||
|
<option value="Login">Login</option>
|
||||||
|
<option value="Logout">Logout</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
|
value={resourceTypeFilter}
|
||||||
|
onChange={(e) => setResourceTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Resources</option>
|
||||||
|
<option value="Client">Client</option>
|
||||||
|
<option value="Subscription">Subscription</option>
|
||||||
|
<option value="User">User</option>
|
||||||
|
<option value="System">System</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading activity logs...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Action
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Log ID</TableHead>
|
||||||
User ID
|
<TableHead>User</TableHead>
|
||||||
</th>
|
<TableHead>Action</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Resource</TableHead>
|
||||||
Resource
|
<TableHead>Resource ID</TableHead>
|
||||||
</th>
|
<TableHead>IP Address</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Timestamp</TableHead>
|
||||||
IP Address
|
<TableHead>Actions</TableHead>
|
||||||
</th>
|
</TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
</TableHeader>
|
||||||
Timestamp
|
<TableBody>
|
||||||
</th>
|
{auditData?.data?.map((log: AuditLog) => (
|
||||||
</tr>
|
<TableRow key={log.id}>
|
||||||
</thead>
|
<TableCell className="font-medium">{log.id}</TableCell>
|
||||||
<tbody className="divide-y">
|
<TableCell>{log.userId || 'N/A'}</TableCell>
|
||||||
{isLoading ? (
|
<TableCell>
|
||||||
<tr>
|
<Badge className={getActionBadgeColor(log.action)}>
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Synchronizing audit records...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : auditData?.data && auditData.data.length > 0 ? (
|
|
||||||
auditData.data.map((log: AuditLog) => (
|
|
||||||
<tr
|
|
||||||
key={log.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
|
||||||
getActionColor(log.action),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{log.action}
|
{log.action}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
|
<TableCell>{log.resourceType}</TableCell>
|
||||||
{log.userId || "SYSTEM"}
|
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
|
||||||
</td>
|
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
|
||||||
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
|
<TableCell>
|
||||||
<Terminal className="w-3 h-3" /> {log.resourceType}:{" "}
|
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')}
|
||||||
{log.resourceId.substring(0, 8)}...
|
</TableCell>
|
||||||
</td>
|
<TableCell>
|
||||||
<td className="px-6 py-4 text-xs font-mono text-gray-500">
|
<Button variant="ghost" size="icon">
|
||||||
{log.ipAddress || "--"}
|
<Eye className="w-4 h-4" />
|
||||||
</td>
|
</Button>
|
||||||
<td className="px-6 py-4 text-right text-xs text-gray-500">
|
</TableCell>
|
||||||
{format(new Date(log.timestamp), "MMM dd, HH:mm:ss")}
|
</TableRow>
|
||||||
</td>
|
))}
|
||||||
</tr>
|
</TableBody>
|
||||||
))
|
</Table>
|
||||||
) : (
|
{auditData?.data?.length === 0 && (
|
||||||
<tr>
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<td
|
No activity logs found
|
||||||
colSpan={5}
|
</div>
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
)}
|
||||||
|
{auditData && auditData.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Page {auditData.page} of {auditData.totalPages} ({auditData.total} total)
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
>
|
>
|
||||||
No activity logs recorded.
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</td>
|
Previous
|
||||||
</tr>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
</tbody>
|
variant="outline"
|
||||||
</table>
|
size="sm"
|
||||||
</div>
|
onClick={() => setPage(p => Math.min(auditData.totalPages, p + 1))}
|
||||||
|
disabled={page === auditData.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{auditData && auditData.totalPages > 1 && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Page {auditData.page} of {auditData.totalPages} ({auditData.total}{" "}
|
|
||||||
records)
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() =>
|
|
||||||
setPage((p) => Math.min(auditData.totalPages, p + 1))
|
|
||||||
}
|
|
||||||
disabled={page === auditData.totalPages}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,154 +1,109 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { analyticsService } from "@/services";
|
import {
|
||||||
import type { ApiUsageData } from "@/types/analytics.types";
|
Table,
|
||||||
import { Activity, Zap, AlertCircle, Clock, Terminal } from "lucide-react";
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { analyticsService } from "@/services"
|
||||||
|
import type { ApiUsageData } from "@/types/analytics.types"
|
||||||
|
|
||||||
export default function AnalyticsApiPage() {
|
export default function AnalyticsApiPage() {
|
||||||
const { data: apiUsage, isLoading } = useQuery({
|
const { data: apiUsage, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "analytics", "api-usage"],
|
queryKey: ['admin', 'analytics', 'api-usage'],
|
||||||
queryFn: () => analyticsService.getApiUsage(7),
|
queryFn: () => analyticsService.getApiUsage(7),
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
||||||
queryKey: ["admin", "analytics", "error-rate"],
|
queryKey: ['admin', 'analytics', 'error-rate'],
|
||||||
queryFn: () => analyticsService.getErrorRate(7),
|
queryFn: () => analyticsService.getErrorRate(7),
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">API Usage Analytics</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
API Performance
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Operational metrics for system integration and service health.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{[
|
<Card>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Total Ingress",
|
<CardTitle className="text-sm font-medium">Total API Calls</CardTitle>
|
||||||
value: errorRate?.total || 0,
|
</CardHeader>
|
||||||
icon: Zap,
|
<CardContent>
|
||||||
color: "text-blue-600 bg-blue-50 border-blue-100",
|
{errorRateLoading ? (
|
||||||
},
|
<div className="text-2xl font-bold">...</div>
|
||||||
{
|
) : (
|
||||||
label: "Transit Errors",
|
<div className="text-2xl font-bold">{errorRate?.total || 0}</div>
|
||||||
value: errorRate?.errors || 0,
|
)}
|
||||||
icon: AlertCircle,
|
</CardContent>
|
||||||
color: "text-rose-600 bg-rose-50 border-rose-100",
|
</Card>
|
||||||
},
|
<Card>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Failure Rate",
|
<CardTitle className="text-sm font-medium">Errors</CardTitle>
|
||||||
value: errorRate?.errorRate
|
</CardHeader>
|
||||||
? `${errorRate.errorRate.toFixed(2)}%`
|
<CardContent>
|
||||||
: "0.00%",
|
{errorRateLoading ? (
|
||||||
icon: Activity,
|
<div className="text-2xl font-bold">...</div>
|
||||||
color: "text-amber-600 bg-amber-50 border-amber-100",
|
) : (
|
||||||
},
|
<div className="text-2xl font-bold">{errorRate?.errors || 0}</div>
|
||||||
].map((metric) => (
|
)}
|
||||||
<Card
|
</CardContent>
|
||||||
key={metric.label}
|
</Card>
|
||||||
className="border shadow-none rounded-none bg-white"
|
<Card>
|
||||||
>
|
<CardHeader>
|
||||||
<CardHeader className="pb-2 space-y-0">
|
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
|
||||||
<CardTitle className="text-[10px] font-bold uppercase tracking-widest text-gray-400 flex items-center justify-between">
|
</CardHeader>
|
||||||
{metric.label}
|
<CardContent>
|
||||||
<metric.icon className="w-3 h-3" />
|
{errorRateLoading ? (
|
||||||
</CardTitle>
|
<div className="text-2xl font-bold">...</div>
|
||||||
</CardHeader>
|
) : (
|
||||||
<CardContent>
|
<div className="text-2xl font-bold">
|
||||||
<div className="flex items-center justify-between">
|
{errorRate?.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
|
||||||
<span className="text-2xl font-bold text-gray-900 tracking-tighter">
|
|
||||||
{errorRateLoading ? "..." : metric.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>Endpoint Usage (Last 7 Days)</CardTitle>
|
||||||
Endpoint Performance Ledger
|
|
||||||
</CardTitle>
|
|
||||||
<Terminal className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading API usage...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Temporal Reference
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
|
<TableHead>Endpoint</TableHead>
|
||||||
Transaction Volume
|
<TableHead>Calls</TableHead>
|
||||||
</th>
|
<TableHead>Avg Duration (ms)</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
</TableRow>
|
||||||
Avg Latency (ms)
|
</TableHeader>
|
||||||
</th>
|
<TableBody>
|
||||||
</tr>
|
{apiUsage?.map((endpoint: ApiUsageData, index: number) => (
|
||||||
</thead>
|
<TableRow key={index}>
|
||||||
<tbody className="divide-y text-gray-600">
|
<TableCell className="font-mono text-sm">{endpoint.date}</TableCell>
|
||||||
{isLoading ? (
|
<TableCell>{endpoint.requests}</TableCell>
|
||||||
<tr>
|
<TableCell>{endpoint.avgResponseTime?.toFixed(2) || 'N/A'}</TableCell>
|
||||||
<td
|
</TableRow>
|
||||||
colSpan={3}
|
))}
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
</TableBody>
|
||||||
>
|
</Table>
|
||||||
Synchronizing performance data...
|
{apiUsage?.length === 0 && (
|
||||||
</td>
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
</tr>
|
No API usage data available
|
||||||
) : apiUsage && apiUsage.length > 0 ? (
|
</div>
|
||||||
apiUsage.map((endpoint: ApiUsageData, index: number) => (
|
)}
|
||||||
<tr
|
</>
|
||||||
key={index}
|
)}
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-3 h-3 text-gray-300" />
|
|
||||||
<span className="text-xs font-mono font-medium text-gray-900">
|
|
||||||
{endpoint.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-center">
|
|
||||||
<span className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{endpoint.requests.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<span className="text-xs font-medium tabular-nums text-gray-500">
|
|
||||||
{endpoint.avgResponseTime?.toFixed(2) || "0.00"}{" "}
|
|
||||||
<span className="text-[10px] text-gray-400 uppercase ml-1">
|
|
||||||
MS
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
No transaction history available.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,86 @@
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import {
|
import { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react"
|
||||||
BarChart3,
|
import { useNavigate } from "react-router-dom"
|
||||||
Users,
|
|
||||||
DollarSign,
|
|
||||||
HardDrive,
|
|
||||||
Activity,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-3xl font-bold">Analytics</h2>
|
<h2 className="text-3xl font-bold">Analytics</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/overview')}>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Performance Overview",
|
<CardTitle className="flex items-center gap-2">
|
||||||
description: "Platform analytics overview",
|
<BarChart3 className="w-5 h-5" />
|
||||||
icon: BarChart3,
|
Overview
|
||||||
path: "/admin/analytics/overview",
|
</CardTitle>
|
||||||
color: "text-blue-600",
|
</CardHeader>
|
||||||
},
|
<CardContent>
|
||||||
{
|
<p className="text-sm text-muted-foreground">
|
||||||
label: "User Dynamics",
|
Platform analytics overview
|
||||||
description: "User growth and statistics",
|
</p>
|
||||||
icon: Users,
|
</CardContent>
|
||||||
path: "/admin/analytics/users",
|
</Card>
|
||||||
color: "text-purple-600",
|
|
||||||
},
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/users')}>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Revenue Streams",
|
<CardTitle className="flex items-center gap-2">
|
||||||
description: "Revenue trends and breakdown",
|
<Users className="w-5 h-5" />
|
||||||
icon: DollarSign,
|
Users Analytics
|
||||||
path: "/admin/analytics/revenue",
|
</CardTitle>
|
||||||
color: "text-emerald-600",
|
</CardHeader>
|
||||||
},
|
<CardContent>
|
||||||
{
|
<p className="text-sm text-muted-foreground">
|
||||||
label: "Resource Allocation",
|
User growth and statistics
|
||||||
description: "Storage usage and breakdown",
|
</p>
|
||||||
icon: HardDrive,
|
</CardContent>
|
||||||
path: "/admin/analytics/storage",
|
</Card>
|
||||||
color: "text-amber-600",
|
|
||||||
},
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/revenue')}>
|
||||||
{
|
<CardHeader>
|
||||||
label: "API Operations",
|
<CardTitle className="flex items-center gap-2">
|
||||||
description: "API endpoint usage statistics",
|
<DollarSign className="w-5 h-5" />
|
||||||
icon: Activity,
|
Revenue Analytics
|
||||||
path: "/admin/analytics/api",
|
</CardTitle>
|
||||||
color: "text-rose-600",
|
</CardHeader>
|
||||||
},
|
<CardContent>
|
||||||
].map((item) => (
|
<p className="text-sm text-muted-foreground">
|
||||||
<Card
|
Revenue trends and breakdown
|
||||||
key={item.label}
|
</p>
|
||||||
className="group cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden"
|
</CardContent>
|
||||||
onClick={() => navigate(item.path)}
|
</Card>
|
||||||
>
|
|
||||||
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/storage')}>
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
<CardTitle className="flex items-center gap-2">
|
||||||
{item.label}
|
<HardDrive className="w-5 h-5" />
|
||||||
</span>
|
Storage Analytics
|
||||||
<item.icon
|
</CardTitle>
|
||||||
className={cn("w-4 h-4 transition-colors", item.color)}
|
</CardHeader>
|
||||||
/>
|
<CardContent>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
Storage usage and breakdown
|
||||||
<CardContent className="pt-4 flex items-end justify-between">
|
</p>
|
||||||
<div>
|
</CardContent>
|
||||||
<p className="text-sm font-semibold text-slate-900 tracking-tight mb-1 group-hover:text-primary transition-colors">
|
</Card>
|
||||||
{item.label}
|
|
||||||
</p>
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/api')}>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-[200px]">
|
<CardHeader>
|
||||||
{item.description}
|
<CardTitle className="flex items-center gap-2">
|
||||||
</p>
|
<Activity className="w-5 h-5" />
|
||||||
</div>
|
API Usage
|
||||||
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:translate-x-1 transition-transform" />
|
</CardTitle>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
<CardContent>
|
||||||
))}
|
<p className="text-sm text-muted-foreground">
|
||||||
|
API endpoint usage statistics
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,211 +1,44 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||||
import { analyticsService } from "@/services";
|
import { analyticsService } from "@/services"
|
||||||
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types";
|
|
||||||
import {
|
|
||||||
HardDrive,
|
|
||||||
FileText,
|
|
||||||
Database,
|
|
||||||
Users,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const COLORS = ["#111827", "#4B5563", "#9CA3AF", "#D1D5DB", "#E2E8F0"];
|
export default function AnalyticsRevenuePage() {
|
||||||
|
const { data: revenue, isLoading } = useQuery({
|
||||||
interface ChartDataItem {
|
queryKey: ['admin', 'analytics', 'revenue'],
|
||||||
name: string;
|
queryFn: () => analyticsService.getRevenue('90days'),
|
||||||
value: number;
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnalyticsStoragePage() {
|
|
||||||
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
|
|
||||||
queryKey: ["admin", "analytics", "storage"],
|
|
||||||
queryFn: () => analyticsService.getStorageAnalytics(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes === 0) return "0 Bytes";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartData: ChartDataItem[] =
|
|
||||||
storage?.byCategory?.map((cat) => ({
|
|
||||||
name: cat.category,
|
|
||||||
value: cat.size,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Revenue Analytics</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Storage Intelligence
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Infrastructure resource allocation and data distribution registry.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<Card>
|
||||||
<Card className="border shadow-none rounded-none bg-white">
|
<CardHeader>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardTitle>Revenue Trends (Last 90 Days)</CardTitle>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
</CardHeader>
|
||||||
Resource Consumption
|
<CardContent>
|
||||||
</CardTitle>
|
{isLoading ? (
|
||||||
<HardDrive className="w-4 h-4 text-gray-300" />
|
<div className="h-[400px] flex items-center justify-center">Loading...</div>
|
||||||
</CardHeader>
|
) : revenue && revenue.length > 0 ? (
|
||||||
<CardContent className="p-8 space-y-8">
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
{isLoading ? (
|
<BarChart data={revenue}>
|
||||||
<div className="h-[300px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
Quantifying resources...
|
<XAxis dataKey="date" />
|
||||||
</div>
|
<YAxis />
|
||||||
) : (
|
<Tooltip />
|
||||||
<>
|
<Legend />
|
||||||
<div className="grid grid-cols-2 gap-8">
|
<Bar dataKey="revenue" fill="#8884d8" name="Revenue" />
|
||||||
<div className="space-y-1">
|
</BarChart>
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
</ResponsiveContainer>
|
||||||
Aggregate Payload
|
) : (
|
||||||
</p>
|
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
|
||||||
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
|
No data available
|
||||||
{storage?.total ? formatBytes(storage.total.size) : "0 B"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Object Registry
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
|
|
||||||
{storage?.total?.files?.toLocaleString() || 0}{" "}
|
|
||||||
<span className="text-xs text-gray-400 uppercase ml-1">
|
|
||||||
Items
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-8 border-t">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<span className="text-[10px] font-bold text-gray-900 uppercase tracking-widest">
|
|
||||||
Efficiency Status
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100">
|
|
||||||
Optimal
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-100 h-2">
|
|
||||||
<div className="bg-gray-900 h-full w-[42%]" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400 font-medium mt-2">
|
|
||||||
42% Cluster Capacity Utilized
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none bg-white">
|
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Distribution by Taxonomy
|
|
||||||
</CardTitle>
|
|
||||||
<Database className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-8">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
) : chartData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
paddingAngle={4}
|
|
||||||
stroke="none"
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{chartData.map((_entry: ChartDataItem, index: number) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={COLORS[index % COLORS.length]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
border: "1px solid #E2E8F0",
|
|
||||||
borderRadius: "0px",
|
|
||||||
boxShadow: "none",
|
|
||||||
fontSize: "10px",
|
|
||||||
fontWeight: "700",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => formatBytes(value)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
|
|
||||||
No category distribution.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{storage?.topUsers && storage.topUsers.length > 0 && (
|
|
||||||
<Card className="border shadow-none rounded-none bg-white">
|
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4 text-gray-400" />
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
High-Consumption Operators
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<FileText className="w-4 h-4 text-gray-300" />
|
)}
|
||||||
</CardHeader>
|
</CardContent>
|
||||||
<CardContent className="p-0">
|
</Card>
|
||||||
<div className="divide-y divide-gray-100">
|
|
||||||
{storage.topUsers.map((user: StorageByUser, index: number) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-8 h-8 flex items-center justify-center bg-gray-50 border text-[10px] font-bold text-gray-400">
|
|
||||||
0{index + 1}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{user.userName || user.email}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
|
|
||||||
{user.documentCount.toLocaleString()} Object References
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
|
||||||
{formatBytes(user.storageUsed)}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="w-4 h-4 text-gray-300 opacity-0 group-hover:opacity-100 transition-all" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +1,71 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
||||||
import { analyticsService } from "@/services";
|
import { analyticsService } from "@/services"
|
||||||
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types";
|
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types"
|
||||||
import {
|
|
||||||
HardDrive,
|
|
||||||
FileText,
|
|
||||||
Database,
|
|
||||||
Users,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const COLORS = ["#111827", "#4B5563", "#9CA3AF", "#D1D5DB", "#E2E8F0"];
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
|
||||||
|
|
||||||
interface ChartDataItem {
|
interface ChartDataItem {
|
||||||
name: string;
|
name: string
|
||||||
value: number;
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalyticsStoragePage() {
|
export default function AnalyticsStoragePage() {
|
||||||
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
|
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
|
||||||
queryKey: ["admin", "analytics", "storage"],
|
queryKey: ['admin', 'analytics', 'storage'],
|
||||||
queryFn: () => analyticsService.getStorageAnalytics(),
|
queryFn: () => analyticsService.getStorageAnalytics(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return '0 Bytes'
|
||||||
const k = 1024;
|
const k = 1024
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||||
};
|
}
|
||||||
|
|
||||||
const chartData: ChartDataItem[] =
|
const chartData: ChartDataItem[] = storage?.byCategory?.map((cat) => ({
|
||||||
storage?.byCategory?.map((cat) => ({
|
name: cat.category,
|
||||||
name: cat.category,
|
value: cat.size,
|
||||||
value: cat.size,
|
})) || []
|
||||||
})) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Storage Analytics</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Storage Intelligence
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Infrastructure resource allocation and data distribution registry.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card className="border shadow-none rounded-none bg-white">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>Storage Overview</CardTitle>
|
||||||
Resource Consumption
|
|
||||||
</CardTitle>
|
|
||||||
<HardDrive className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-8 space-y-8">
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-[300px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
||||||
Quantifying resources...
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-8">
|
<div>
|
||||||
<div className="space-y-1">
|
<p className="text-sm text-muted-foreground">Total Storage</p>
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<p className="text-2xl font-bold">
|
||||||
Aggregate Payload
|
{storage?.total ? formatBytes(storage.total.size) : '0 Bytes'}
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
|
|
||||||
{storage?.total ? formatBytes(storage.total.size) : "0 B"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Object Registry
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
|
|
||||||
{storage?.total?.files?.toLocaleString() || 0}{" "}
|
|
||||||
<span className="text-xs text-gray-400 uppercase ml-1">
|
|
||||||
Items
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-8 border-t">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<span className="text-[10px] font-bold text-gray-900 uppercase tracking-widest">
|
|
||||||
Efficiency Status
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100">
|
|
||||||
Optimal
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-100 h-2">
|
|
||||||
<div className="bg-gray-900 h-full w-[42%]" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400 font-medium mt-2">
|
|
||||||
42% Cluster Capacity Utilized
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Files</p>
|
||||||
|
<p className="text-2xl font-bold">{storage?.total?.files || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none bg-white">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>Storage by Category</CardTitle>
|
||||||
Distribution by Taxonomy
|
|
||||||
</CardTitle>
|
|
||||||
<Database className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-8">
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
||||||
...
|
|
||||||
</div>
|
|
||||||
) : chartData.length > 0 ? (
|
) : chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
|
@ -127,36 +73,23 @@ export default function AnalyticsStoragePage() {
|
||||||
data={chartData}
|
data={chartData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={60}
|
labelLine={false}
|
||||||
outerRadius={100}
|
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||||
paddingAngle={4}
|
outerRadius={80}
|
||||||
stroke="none"
|
fill="#8884d8"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
{chartData.map((_entry: ChartDataItem, index: number) => (
|
{chartData.map((_entry: ChartDataItem, index: number) => (
|
||||||
<Cell
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={COLORS[index % COLORS.length]}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip />
|
||||||
contentStyle={{
|
<Legend />
|
||||||
backgroundColor: "#fff",
|
|
||||||
border: "1px solid #E2E8F0",
|
|
||||||
borderRadius: "0px",
|
|
||||||
boxShadow: "none",
|
|
||||||
fontSize: "10px",
|
|
||||||
fontWeight: "700",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => formatBytes(value)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
|
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
No category distribution.
|
No data available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -164,42 +97,19 @@ export default function AnalyticsStoragePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{storage?.topUsers && storage.topUsers.length > 0 && (
|
{storage?.topUsers && storage.topUsers.length > 0 && (
|
||||||
<Card className="border shadow-none rounded-none bg-white">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle>Top 10 Users by Storage Usage</CardTitle>
|
||||||
<Users className="w-4 h-4 text-gray-400" />
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
High-Consumption Operators
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
<FileText className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="space-y-2">
|
||||||
{storage.topUsers.map((user: StorageByUser, index: number) => (
|
{storage.topUsers.map((user: StorageByUser, index: number) => (
|
||||||
<div
|
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
key={index}
|
<div>
|
||||||
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
|
<p className="font-medium">{user.userName || user.email}</p>
|
||||||
>
|
<p className="text-sm text-muted-foreground">{user.documentCount} files</p>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-8 h-8 flex items-center justify-center bg-gray-50 border text-[10px] font-bold text-gray-400">
|
|
||||||
0{index + 1}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{user.userName || user.email}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
|
|
||||||
{user.documentCount.toLocaleString()} Object References
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
|
||||||
{formatBytes(user.storageUsed)}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="w-4 h-4 text-gray-300 opacity-0 group-hover:opacity-100 transition-all" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="font-medium">{formatBytes(user.storageUsed)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,5 +117,6 @@ export default function AnalyticsStoragePage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,46 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import {
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||||
LineChart,
|
import { analyticsService } from "@/services"
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
import { analyticsService } from "@/services";
|
|
||||||
import { Users, TrendingUp, UserCheck, ShieldCheck } from "lucide-react";
|
|
||||||
|
|
||||||
export default function AnalyticsUsersPage() {
|
export default function AnalyticsUsersPage() {
|
||||||
const { data: userGrowth, isLoading } = useQuery({
|
const { data: userGrowth, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "analytics", "users", "growth"],
|
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
||||||
queryFn: () => analyticsService.getUserGrowth(90),
|
queryFn: () => analyticsService.getUserGrowth(90),
|
||||||
});
|
})
|
||||||
|
|
||||||
// Calculate some dummy metrics based on the last data point if available
|
|
||||||
const lastMetrics = userGrowth?.[userGrowth.length - 1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">User Analytics</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
User Intelligence
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Growth trajectories and demographic distribution patterns.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<Card>
|
||||||
{[
|
<CardHeader>
|
||||||
{
|
<CardTitle>User Growth (Last 90 Days)</CardTitle>
|
||||||
label: "Total Population",
|
|
||||||
value: lastMetrics?.total || 0,
|
|
||||||
icon: Users,
|
|
||||||
color: "text-blue-600 bg-blue-50 border-blue-100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Privileged Access",
|
|
||||||
value: lastMetrics?.admins || 0,
|
|
||||||
icon: ShieldCheck,
|
|
||||||
color: "text-emerald-600 bg-emerald-50 border-emerald-100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Standard Users",
|
|
||||||
value: lastMetrics?.regular || 0,
|
|
||||||
icon: UserCheck,
|
|
||||||
color: "text-purple-600 bg-purple-50 border-purple-100",
|
|
||||||
},
|
|
||||||
].map((metric) => (
|
|
||||||
<Card
|
|
||||||
key={metric.label}
|
|
||||||
className="border shadow-none rounded-none bg-white"
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-2 space-y-0">
|
|
||||||
<CardTitle className="text-[10px] font-bold uppercase tracking-widest text-gray-400 flex items-center justify-between">
|
|
||||||
{metric.label}
|
|
||||||
<metric.icon className="w-3 h-3" />
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-2xl font-bold text-gray-900 tracking-tighter">
|
|
||||||
{isLoading
|
|
||||||
? "..."
|
|
||||||
: (metric.value as number).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Growth Trajectory (Last 90 Days)
|
|
||||||
</CardTitle>
|
|
||||||
<TrendingUp className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-8">
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-[400px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
<div className="h-[400px] flex items-center justify-center">Loading...</div>
|
||||||
Visualizing temporal growth...
|
|
||||||
</div>
|
|
||||||
) : userGrowth && userGrowth.length > 0 ? (
|
) : userGrowth && userGrowth.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<LineChart
|
<LineChart data={userGrowth}>
|
||||||
data={userGrowth}
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
margin={{ top: 5, right: 30, left: 10, bottom: 5 }}
|
<XAxis dataKey="date" />
|
||||||
>
|
<YAxis />
|
||||||
<CartesianGrid
|
<Tooltip />
|
||||||
strokeDasharray="1 4"
|
<Legend />
|
||||||
vertical={false}
|
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" />
|
||||||
stroke="#E2E8F0"
|
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" />
|
||||||
/>
|
<Line type="monotone" dataKey="regular" stroke="#ffc658" name="Regular Users" />
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tick={{ fontSize: 10, fontWeight: 700, fill: "#94A3B8" }}
|
|
||||||
dy={10}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tick={{ fontSize: 10, fontWeight: 700, fill: "#94A3B8" }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
border: "1px solid #E2E8F0",
|
|
||||||
borderRadius: "0px",
|
|
||||||
boxShadow: "none",
|
|
||||||
fontSize: "10px",
|
|
||||||
fontWeight: "700",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="total"
|
|
||||||
stroke="#111827"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 4, fill: "#111827", strokeWidth: 0 }}
|
|
||||||
name="Aggregate Population"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="admins"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
dot={false}
|
|
||||||
name="Privileged"
|
|
||||||
/>
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[400px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
|
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
|
||||||
Incomplete trajectory data.
|
No data available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -9,433 +18,329 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog"
|
||||||
import { Megaphone, Plus, Edit, Trash2, Filter } from "lucide-react";
|
import { Plus, Edit, Trash2 } from "lucide-react"
|
||||||
import {
|
import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services"
|
||||||
announcementService,
|
import { toast } from "sonner"
|
||||||
type Announcement,
|
import { format } from "date-fns"
|
||||||
type CreateAnnouncementData,
|
import type { ApiError } from "@/types/error.types"
|
||||||
} 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 AnnouncementsPage() {
|
export default function AnnouncementsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [formDialogOpen, setFormDialogOpen] = useState(false);
|
const [formDialogOpen, setFormDialogOpen] = useState(false)
|
||||||
const [selectedAnnouncement, setSelectedAnnouncement] =
|
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null)
|
||||||
useState<Announcement | null>(null);
|
|
||||||
const [formData, setFormData] = useState<CreateAnnouncementData>({
|
const [formData, setFormData] = useState<CreateAnnouncementData>({
|
||||||
title: "",
|
title: '',
|
||||||
message: "",
|
message: '',
|
||||||
type: "info" as "info" | "warning" | "success" | "error",
|
type: 'info' as 'info' | 'warning' | 'success' | 'error',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
targetAudience: "all",
|
targetAudience: 'all',
|
||||||
startsAt: "",
|
startsAt: '',
|
||||||
endsAt: "",
|
endsAt: '',
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: announcements, isLoading } = useQuery({
|
const { data: announcements, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "announcements"],
|
queryKey: ['admin', 'announcements'],
|
||||||
queryFn: () => announcementService.getAnnouncements(false),
|
queryFn: () => announcementService.getAnnouncements(false),
|
||||||
});
|
})
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateAnnouncementData) =>
|
mutationFn: (data: CreateAnnouncementData) => announcementService.createAnnouncement(data),
|
||||||
announcementService.createAnnouncement(data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "announcements"] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
||||||
toast.success("Announcement created successfully");
|
toast.success("Announcement created successfully")
|
||||||
setFormDialogOpen(false);
|
setFormDialogOpen(false)
|
||||||
resetForm();
|
resetForm()
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to create announcement")
|
||||||
apiError.response?.data?.message || "Failed to create announcement",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: CreateAnnouncementData }) =>
|
mutationFn: ({ id, data }: { id: string; data: CreateAnnouncementData }) =>
|
||||||
announcementService.updateAnnouncement(id, data),
|
announcementService.updateAnnouncement(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "announcements"] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
||||||
toast.success("Announcement updated successfully");
|
toast.success("Announcement updated successfully")
|
||||||
setFormDialogOpen(false);
|
setFormDialogOpen(false)
|
||||||
resetForm();
|
resetForm()
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to update announcement")
|
||||||
apiError.response?.data?.message || "Failed to update announcement",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => announcementService.deleteAnnouncement(id),
|
mutationFn: (id: string) => announcementService.deleteAnnouncement(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "announcements"] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
||||||
toast.success("Announcement deleted successfully");
|
toast.success("Announcement deleted successfully")
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false)
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to delete announcement")
|
||||||
apiError.response?.data?.message || "Failed to delete announcement",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
title: "",
|
title: '',
|
||||||
message: "",
|
message: '',
|
||||||
type: "info",
|
type: 'info',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
targetAudience: "all",
|
targetAudience: 'all',
|
||||||
startsAt: "",
|
startsAt: '',
|
||||||
endsAt: "",
|
endsAt: '',
|
||||||
});
|
})
|
||||||
setSelectedAnnouncement(null);
|
setSelectedAnnouncement(null)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleOpenCreateDialog = () => {
|
const handleOpenCreateDialog = () => {
|
||||||
resetForm();
|
resetForm()
|
||||||
setFormDialogOpen(true);
|
setFormDialogOpen(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleOpenEditDialog = (announcement: Announcement) => {
|
const handleOpenEditDialog = (announcement: Announcement) => {
|
||||||
setSelectedAnnouncement(announcement);
|
setSelectedAnnouncement(announcement)
|
||||||
setFormData({
|
setFormData({
|
||||||
title: announcement.title || "",
|
title: announcement.title || '',
|
||||||
message: announcement.message || "",
|
message: announcement.message || '',
|
||||||
type: announcement.type || "info",
|
type: announcement.type || 'info',
|
||||||
priority: announcement.priority || 0,
|
priority: announcement.priority || 0,
|
||||||
targetAudience: announcement.targetAudience || "all",
|
targetAudience: announcement.targetAudience || 'all',
|
||||||
startsAt: announcement.startsAt
|
startsAt: announcement.startsAt ? announcement.startsAt.split('T')[0] : '',
|
||||||
? announcement.startsAt.split("T")[0]
|
endsAt: announcement.endsAt ? announcement.endsAt.split('T')[0] : '',
|
||||||
: "",
|
})
|
||||||
endsAt: announcement.endsAt ? announcement.endsAt.split("T")[0] : "",
|
setFormDialogOpen(true)
|
||||||
});
|
}
|
||||||
setFormDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
|
|
||||||
if (!formData.title || !formData.message) {
|
if (!formData.title || !formData.message) {
|
||||||
toast.error("Title and message are required");
|
toast.error("Title and message are required")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...formData,
|
...formData,
|
||||||
startsAt: formData.startsAt || undefined,
|
startsAt: formData.startsAt || undefined,
|
||||||
endsAt: formData.endsAt || undefined,
|
endsAt: formData.endsAt || undefined,
|
||||||
};
|
|
||||||
if (selectedAnnouncement) {
|
|
||||||
updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData });
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(submitData);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if (selectedAnnouncement) {
|
||||||
|
updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(submitData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (selectedAnnouncement) {
|
if (selectedAnnouncement) {
|
||||||
deleteMutation.mutate(selectedAnnouncement.id);
|
deleteMutation.mutate(selectedAnnouncement.id)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getBadgeStyle = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "warning":
|
|
||||||
return "bg-amber-50 text-amber-600 border-amber-100";
|
|
||||||
case "error":
|
|
||||||
return "bg-rose-50 text-rose-600 border-rose-100";
|
|
||||||
case "success":
|
|
||||||
return "bg-emerald-50 text-emerald-600 border-emerald-100";
|
|
||||||
default:
|
|
||||||
return "bg-blue-50 text-blue-600 border-blue-100";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<h2 className="text-3xl font-bold">Announcements</h2>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<Button onClick={handleOpenCreateDialog}>
|
||||||
Announcements
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
</h1>
|
Create Announcement
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Broadcast important information to users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleOpenCreateDialog}
|
|
||||||
className="rounded-none font-bold uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" /> Create Announcement
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-end justify-end space-y-0">
|
<CardHeader>
|
||||||
<Button
|
<CardTitle>All Announcements</CardTitle>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading announcements...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Title
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Title</TableHead>
|
||||||
Status
|
<TableHead>Type</TableHead>
|
||||||
</th>
|
<TableHead>Priority</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Status</TableHead>
|
||||||
Type
|
<TableHead>Start Date</TableHead>
|
||||||
</th>
|
<TableHead>End Date</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Actions</TableHead>
|
||||||
Scheduled
|
</TableRow>
|
||||||
</th>
|
</TableHeader>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<TableBody>
|
||||||
Actions
|
{announcements?.map((announcement: Announcement) => (
|
||||||
</th>
|
<TableRow key={announcement.id}>
|
||||||
</tr>
|
<TableCell className="font-medium">{announcement.title}</TableCell>
|
||||||
</thead>
|
<TableCell>
|
||||||
<tbody className="divide-y">
|
<Badge>{announcement.type || 'info'}</Badge>
|
||||||
{isLoading ? (
|
</TableCell>
|
||||||
<tr>
|
<TableCell>{announcement.priority || 0}</TableCell>
|
||||||
<td
|
<TableCell>
|
||||||
colSpan={5}
|
<Badge variant={announcement.isActive ? 'default' : 'secondary'}>
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
{announcement.isActive ? 'Active' : 'Inactive'}
|
||||||
>
|
</Badge>
|
||||||
Loading...
|
</TableCell>
|
||||||
</td>
|
<TableCell>
|
||||||
</tr>
|
{announcement.startsAt ? format(new Date(announcement.startsAt), 'MMM dd, yyyy') : 'N/A'}
|
||||||
) : announcements && announcements.length > 0 ? (
|
</TableCell>
|
||||||
announcements.map((announcement: Announcement) => (
|
<TableCell>
|
||||||
<tr
|
{announcement.endsAt ? format(new Date(announcement.endsAt), 'MMM dd, yyyy') : 'N/A'}
|
||||||
key={announcement.id}
|
</TableCell>
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
<TableCell>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<td className="px-6 py-4 max-w-sm">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-gray-900 truncate">
|
|
||||||
{announcement.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400 truncate">
|
|
||||||
{announcement.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
|
||||||
announcement.isActive
|
|
||||||
? "bg-emerald-50 text-emerald-600 border-emerald-100"
|
|
||||||
: "bg-slate-50 text-slate-600 border-slate-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{announcement.isActive ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border capitalize",
|
|
||||||
getBadgeStyle(announcement.type || "info"),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{announcement.type || "info"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col text-[10px] text-gray-400">
|
|
||||||
<span>
|
|
||||||
{announcement.startsAt
|
|
||||||
? format(
|
|
||||||
new Date(announcement.startsAt),
|
|
||||||
"MMM dd",
|
|
||||||
)
|
|
||||||
: "--"}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
→{" "}
|
|
||||||
{announcement.endsAt
|
|
||||||
? format(new Date(announcement.endsAt), "MMM dd")
|
|
||||||
: "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => handleOpenEditDialog(announcement)}
|
onClick={() => handleOpenEditDialog(announcement)}
|
||||||
>
|
>
|
||||||
<Edit className="w-3.5 h-3.5 text-gray-400" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedAnnouncement(announcement);
|
setSelectedAnnouncement(announcement)
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 text-gray-400" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableBody>
|
||||||
<tr>
|
</Table>
|
||||||
<td
|
{announcements?.length === 0 && (
|
||||||
colSpan={5}
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
No announcements found
|
||||||
>
|
</div>
|
||||||
No active announcements.
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
)}
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Form Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<Dialog open={formDialogOpen} onOpenChange={setFormDialogOpen}>
|
<Dialog open={formDialogOpen} onOpenChange={setFormDialogOpen}>
|
||||||
<DialogContent className="max-w-xl rounded-none border border-gray-200">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-bold uppercase tracking-widest text-gray-900 border-b pb-2">
|
<DialogTitle>
|
||||||
{selectedAnnouncement ? "Edit Broadcast" : "New Broadcast"}
|
{selectedAnnouncement ? 'Edit Announcement' : 'Create Announcement'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedAnnouncement
|
||||||
|
? 'Update the announcement details below.'
|
||||||
|
: 'Fill in the details to create a new announcement.'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 pt-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1.5">
|
<label className="text-sm font-medium">Title *</label>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
<input
|
||||||
Announcement Title
|
type="text"
|
||||||
</label>
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Message *</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full px-3 py-2 border rounded-md min-h-[100px]"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Type</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'info' | 'warning' | 'success' | 'error' })}
|
||||||
|
>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
<option value="success">Success</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Priority</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="number"
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm font-medium focus:ring-1 focus:ring-gray-900 focus:outline-none"
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
value={formData.title}
|
value={formData.priority}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })}
|
||||||
setFormData({ ...formData, title: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
BroadCast Message
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm font-medium min-h-[120px] focus:ring-1 focus:ring-gray-900 focus:outline-none"
|
|
||||||
value={formData.message}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, message: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Type
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm appearance-none bg-white focus:ring-1 focus:ring-gray-900 focus:outline-none"
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, type: e.target.value as any })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="info">Information</option>
|
|
||||||
<option value="warning">Warning</option>
|
|
||||||
<option value="success">Success</option>
|
|
||||||
<option value="error">Critical</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Target Audience
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm focus:ring-1 focus:ring-gray-900 focus:outline-none"
|
|
||||||
value={formData.targetAudience}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
targetAudience: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Launch Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm focus:outline-none"
|
|
||||||
value={formData.startsAt}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, startsAt: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Expiry Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm focus:outline-none"
|
|
||||||
value={formData.endsAt}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, endsAt: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="border-t pt-4">
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Target Audience</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
value={formData.targetAudience}
|
||||||
|
onChange={(e) => setFormData({ ...formData, targetAudience: e.target.value })}
|
||||||
|
placeholder="all, admins, users, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
value={formData.startsAt}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startsAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
value={formData.endsAt}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endsAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
onClick={() => setFormDialogOpen(false)}
|
onClick={() => {
|
||||||
className="rounded-none text-xs uppercase tracking-widest"
|
setFormDialogOpen(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-none text-xs uppercase tracking-widest px-8"
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
>
|
>
|
||||||
Confirm
|
{selectedAnnouncement ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -444,34 +349,23 @@ export default function AnnouncementsPage() {
|
||||||
|
|
||||||
{/* Delete Dialog */}
|
{/* Delete Dialog */}
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent className="rounded-none border-rose-100">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-rose-600 text-sm font-bold uppercase tracking-widest">
|
<DialogTitle>Delete Announcement</DialogTitle>
|
||||||
Delete BroadCast
|
<DialogDescription>
|
||||||
</DialogTitle>
|
Are you sure you want to delete "{selectedAnnouncement?.title}"? This action cannot be undone.
|
||||||
<DialogDescription className="text-xs">
|
|
||||||
Confirm removal of "{selectedAnnouncement?.title}". This operation
|
|
||||||
cannot be reversed.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
className="rounded-none text-xs"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
variant="destructive"
|
Delete
|
||||||
onClick={handleDelete}
|
|
||||||
className="rounded-none text-xs"
|
|
||||||
>
|
|
||||||
Permanent Delete
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1,105 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Search,
|
Table,
|
||||||
Eye,
|
TableBody,
|
||||||
ChevronLeft,
|
TableCell,
|
||||||
ChevronRight,
|
TableHead,
|
||||||
Filter,
|
TableHeader,
|
||||||
Terminal,
|
TableRow,
|
||||||
} from "lucide-react";
|
} from "@/components/ui/table"
|
||||||
import { auditService, type AuditLog } from "@/services";
|
import { Search, Eye } from "lucide-react"
|
||||||
import { format } from "date-fns";
|
import { auditService, type AuditLog } from "@/services"
|
||||||
import { cn } from "@/lib/utils";
|
import { format } from "date-fns"
|
||||||
|
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page] = useState(1)
|
||||||
const [limit] = useState(15);
|
const [limit] = useState(50)
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const { data: auditData, isLoading } = useQuery({
|
const { data: auditData, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "audit", "logs", page, limit, search],
|
queryKey: ['admin', 'audit', 'logs', page, limit, search],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: Record<string, string | number> = { page, limit };
|
const params: Record<string, string | number> = { page, limit }
|
||||||
if (search) params.search = search;
|
if (search) params.search = search
|
||||||
return await auditService.getAuditLogs(params);
|
return await auditService.getAuditLogs(params)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const getActionColor = (action: string) => {
|
|
||||||
const act = action.toUpperCase();
|
|
||||||
if (act.includes("CREATE"))
|
|
||||||
return "text-blue-600 bg-blue-50 border-blue-100";
|
|
||||||
if (act.includes("UPDATE"))
|
|
||||||
return "text-emerald-600 bg-emerald-50 border-emerald-100";
|
|
||||||
if (act.includes("DELETE"))
|
|
||||||
return "text-rose-600 bg-rose-50 border-rose-100";
|
|
||||||
if (act.includes("LOGIN"))
|
|
||||||
return "text-purple-600 bg-purple-50 border-purple-100";
|
|
||||||
return "text-slate-600 bg-slate-50 border-slate-100";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Audit Logs</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Audit Logs
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Comprehensive system transaction registry.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* View only access: No administrative actions */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
Security Ledger
|
<CardTitle>All Audit Logs</CardTitle>
|
||||||
</CardTitle>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative w-64">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Input
|
||||||
<Input
|
placeholder="Search audit logs..."
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
className="pl-10 w-64"
|
||||||
placeholder="Search resources..."
|
value={search}
|
||||||
value={search}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading audit logs...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Act
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Action</TableHead>
|
||||||
User ID
|
<TableHead>Resource Type</TableHead>
|
||||||
</th>
|
<TableHead>Resource ID</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>User</TableHead>
|
||||||
Resource
|
<TableHead>IP Address</TableHead>
|
||||||
</th>
|
<TableHead>Date</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Actions</TableHead>
|
||||||
IP
|
</TableRow>
|
||||||
</th>
|
</TableHeader>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<TableBody>
|
||||||
Date
|
{auditData?.data?.map((log: AuditLog) => (
|
||||||
</th>
|
<TableRow key={log.id}>
|
||||||
</tr>
|
<TableCell>
|
||||||
</thead>
|
<Badge>{log.action}</Badge>
|
||||||
<tbody className="divide-y">
|
</TableCell>
|
||||||
{isLoading ? (
|
<TableCell>{log.resourceType}</TableCell>
|
||||||
<tr>
|
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
|
||||||
<td
|
<TableCell>{log.userId || 'N/A'}</TableCell>
|
||||||
colSpan={5}
|
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
<TableCell>
|
||||||
>
|
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')}
|
||||||
Retrieving audit trail...
|
</TableCell>
|
||||||
</td>
|
<TableCell>
|
||||||
</tr>
|
<Button variant="ghost" size="icon">
|
||||||
) : auditData?.data && auditData.data.length > 0 ? (
|
<Eye className="w-4 h-4" />
|
||||||
auditData.data.map((log: AuditLog) => (
|
</Button>
|
||||||
<tr
|
</TableCell>
|
||||||
key={log.id}
|
</TableRow>
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
))}
|
||||||
>
|
</TableBody>
|
||||||
<td className="px-6 py-4">
|
</Table>
|
||||||
<span
|
{auditData?.data?.length === 0 && (
|
||||||
className={cn(
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
No audit logs found
|
||||||
getActionColor(log.action),
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
</>
|
||||||
{log.action}
|
)}
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{log.userId || "--"}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
|
|
||||||
<Terminal className="w-3 h-3" /> {log.resourceType}:{" "}
|
|
||||||
{log.resourceId.substring(0, 10)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-xs font-mono text-gray-500">
|
|
||||||
{log.ipAddress || "--"}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right text-xs text-gray-500 font-medium">
|
|
||||||
{format(new Date(log.timestamp), "MMM dd, HH:mm")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Security ledger is clear.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{auditData && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Standard View: {auditData.total || 0} Entries
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
disabled={!auditData?.data || auditData.data.length < limit}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,378 +1,324 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Link } from "react-router-dom";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import {
|
import { Button } from "@/components/ui/button"
|
||||||
Receipt,
|
import { Download, Users, FileText, DollarSign, HardDrive, AlertCircle } from "lucide-react"
|
||||||
FileSearch,
|
import { analyticsService, systemService } from "@/services"
|
||||||
ClipboardList,
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||||
CreditCard,
|
import { toast } from "sonner"
|
||||||
FileClock,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { dashboardService, invoiceService } from "@/services";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const COLORS = ["#10b981", "#f59e0b", "#ef4444", "#3b82f6", "#8b5cf6"];
|
|
||||||
|
|
||||||
const COMMERCE_QUICK_LINKS = [
|
|
||||||
{
|
|
||||||
label: "Invoices",
|
|
||||||
description: "Browse, search, and manage issued invoices.",
|
|
||||||
path: "/admin/invoices",
|
|
||||||
icon: Receipt,
|
|
||||||
color: "text-slate-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Proforma",
|
|
||||||
description: "View and manage proforma invoices and drafts.",
|
|
||||||
path: "/admin/proforma",
|
|
||||||
icon: FileSearch,
|
|
||||||
color: "text-blue-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Proforma requests",
|
|
||||||
description: "Review and process incoming proforma requests.",
|
|
||||||
path: "/admin/proforma-requests",
|
|
||||||
icon: ClipboardList,
|
|
||||||
color: "text-violet-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Payments",
|
|
||||||
description: "Recorded payments and transaction history.",
|
|
||||||
path: "/admin/payments",
|
|
||||||
icon: CreditCard,
|
|
||||||
color: "text-emerald-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Payment requests",
|
|
||||||
description: "Pending and processed payment requests.",
|
|
||||||
path: "/admin/payment-requests",
|
|
||||||
icon: FileClock,
|
|
||||||
color: "text-amber-600",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data: metrics, isLoading: metricsLoading } = useQuery({
|
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||||
queryKey: ["admin", "dashboard", "metrics"],
|
queryKey: ['admin', 'analytics', 'overview'],
|
||||||
queryFn: () => dashboardService.getMetrics(),
|
queryFn: () => analyticsService.getOverview(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: scannedInvoices, isLoading: scannedLoading } = useQuery({
|
const { data: userGrowth, isLoading: growthLoading } = useQuery({
|
||||||
queryKey: ["admin", "dashboard", "scanned"],
|
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
||||||
queryFn: () => dashboardService.getScannedInvoices(),
|
queryFn: () => analyticsService.getUserGrowth(30),
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: statusBreakdown, isLoading: statusLoading } = useQuery({
|
const { data: revenue, isLoading: revenueLoading } = useQuery({
|
||||||
queryKey: ["admin", "dashboard", "status-breakdown"],
|
queryKey: ['admin', 'analytics', 'revenue'],
|
||||||
queryFn: () => dashboardService.getInvoiceStatusBreakdown(),
|
queryFn: () => analyticsService.getRevenue('30days'),
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: proformaRequests, isLoading: requestsLoading } = useQuery({
|
const { data: health, isLoading: healthLoading } = useQuery({
|
||||||
queryKey: ["admin", "dashboard", "proforma-requests"],
|
queryKey: ['admin', 'system', 'health'],
|
||||||
queryFn: () => invoiceService.getProformaRequests({ limit: 5 }),
|
queryFn: () => systemService.getHealth(),
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'error-rate'],
|
||||||
|
queryFn: () => analyticsService.getErrorRate(7),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
try {
|
||||||
|
// Create CSV content from current dashboard data
|
||||||
|
const csvContent = [
|
||||||
|
['Metric', 'Value'],
|
||||||
|
['Total Users', overview?.users?.total || 0],
|
||||||
|
['Active Users', overview?.users?.active || 0],
|
||||||
|
['Inactive Users', overview?.users?.inactive || 0],
|
||||||
|
['Total Invoices', overview?.invoices?.total || 0],
|
||||||
|
['Total Revenue', overview?.revenue?.total || 0],
|
||||||
|
['Storage Used', overview?.storage?.totalSize || 0],
|
||||||
|
['Total Documents', overview?.storage?.documents || 0],
|
||||||
|
['Error Rate', errorRate?.errorRate || 0],
|
||||||
|
['Total Errors', errorRate?.errors || 0],
|
||||||
|
['Export Date', new Date().toISOString()],
|
||||||
|
]
|
||||||
|
.map(row => row.join(','))
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
// Create and download the file
|
||||||
|
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 = `admin-dashboard-${new Date().toISOString().split('T')[0]}.csv`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
toast.success("Dashboard data exported successfully!")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to export data. Please try again.")
|
||||||
|
console.error('Export error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: "currency",
|
style: 'currency',
|
||||||
currency: "USD",
|
currency: 'USD',
|
||||||
maximumFractionDigits: 0,
|
}).format(amount)
|
||||||
}).format(amount);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const dataLoading =
|
const formatBytes = (bytes: number) => {
|
||||||
metricsLoading || scannedLoading || statusLoading || requestsLoading;
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-12 max-w-7xl mx-auto bg-white min-h-screen">
|
<div className="space-y-6">
|
||||||
<header>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<h2 className="text-3xl font-bold">Dashboard Overview</h2>
|
||||||
Dashboard Overview
|
<div className="flex items-center gap-4">
|
||||||
</h1>
|
<div className="text-sm text-muted-foreground">
|
||||||
<p className="text-gray-500 mt-1">
|
{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
Operational status and pending verification items.
|
</div>
|
||||||
</p>
|
<Button variant="outline" onClick={handleExport}>
|
||||||
</header>
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export Data
|
||||||
{/* Quick access — invoices, proforma, payments */}
|
</Button>
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Quick access
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
|
||||||
{COMMERCE_QUICK_LINKS.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className="group block rounded-none outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Card className="h-full cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden">
|
|
||||||
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
<item.icon
|
|
||||||
className={cn(
|
|
||||||
"w-4 h-4 shrink-0 transition-colors",
|
|
||||||
item.color,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-3 pb-4 flex items-end justify-between gap-2">
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
<ChevronRight className="w-4 h-4 shrink-0 text-slate-300 group-hover:translate-x-0.5 transition-transform" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{dataLoading ? (
|
{/* Stats Cards */}
|
||||||
<div className="py-16 text-center text-gray-500 font-medium border border-dashed border-gray-200 rounded-none">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
Loading dashboard data…
|
<Card>
|
||||||
</div>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
) : (
|
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||||
<>
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
{/* Top Metrics Cards */}
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<Card className="border-none bg-gray-50 shadow-none rounded-none">
|
|
||||||
<CardHeader className="pb-1">
|
|
||||||
<CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Gross Revenue
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-black text-gray-900">
|
{overviewLoading ? (
|
||||||
{formatCurrency(metrics?.totalRevenue || 0)}
|
<div className="text-2xl font-bold">...</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold">{overview?.users?.total || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{overview?.users?.active || 0} active, {overview?.users?.inactive || 0} inactive
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-none bg-gray-50 shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="pb-1">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
|
||||||
Total Payments
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-black text-gray-900">
|
{overviewLoading ? (
|
||||||
{(metrics?.totalPayments || 0).toLocaleString()}
|
<div className="text-2xl font-bold">...</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold">{overview?.invoices?.total || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
All time invoices
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-none bg-gray-50 shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="pb-1">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||||
Total Invoices
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-black text-gray-900">
|
{overviewLoading ? (
|
||||||
{(metrics?.totalInvoices || 0).toLocaleString()}
|
<div className="text-2xl font-bold">...</div>
|
||||||
</div>
|
) : (
|
||||||
</CardContent>
|
<>
|
||||||
</Card>
|
<div className="text-2xl font-bold">
|
||||||
</section>
|
{overview?.revenue ? formatCurrency(overview.revenue.total) : '$0.00'}
|
||||||
|
|
||||||
{/* Invoice Status Breakdown (Full Width) */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">
|
|
||||||
Invoice Status Breakdown
|
|
||||||
</h2>
|
|
||||||
<div className="border bg-white divide-y overflow-hidden rounded-none">
|
|
||||||
{statusBreakdown && statusBreakdown.length > 0 ? (
|
|
||||||
statusBreakdown.map((item, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="p-4 flex items-center justify-between hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-2.5 h-2.5"
|
|
||||||
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-bold uppercase tracking-wide text-gray-700">
|
|
||||||
{item.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-black text-gray-900">
|
<p className="text-xs text-muted-foreground">
|
||||||
{item.count.toLocaleString()}
|
Total revenue
|
||||||
</span>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Storage Usage</CardTitle>
|
||||||
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<div className="text-2xl font-bold">...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{overview?.storage ? formatBytes(overview.storage.totalSize) : '0 Bytes'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{overview?.storage?.documents || 0} documents
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* User Growth Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Growth (Last 30 Days)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{growthLoading ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
||||||
|
) : userGrowth && userGrowth.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={userGrowth}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" />
|
||||||
|
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" />
|
||||||
|
<Line type="monotone" dataKey="regular" stroke="#ffc658" name="Regular Users" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
|
No data available
|
||||||
</div>
|
</div>
|
||||||
))
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Revenue Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Revenue Analytics (Last 30 Days)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{revenueLoading ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
||||||
|
) : revenue && revenue.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={revenue}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="revenue" fill="#8884d8" name="Revenue" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Rate Chart */}
|
||||||
|
{errorRate && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Error Rate (Last 7 Days)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{errorRateLoading ? (
|
||||||
|
<div className="h-[200px] flex items-center justify-center">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Errors</p>
|
||||||
|
<p className="text-2xl font-bold">{errorRate.errors || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Requests</p>
|
||||||
|
<p className="text-2xl font-bold">{errorRate.total || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Error Rate</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{errorRate.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-4">
|
||||||
|
<div
|
||||||
|
className="bg-destructive h-4 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(errorRate.errorRate || 0, 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System Health */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
System Health
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{healthLoading ? (
|
||||||
|
<div>Loading system health...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-12 text-center text-gray-400 italic">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
No status data available.
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Status</p>
|
||||||
|
<p className="text-lg font-semibold capitalize">{health?.status || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Database</p>
|
||||||
|
<p className="text-lg font-semibold capitalize">{health?.database || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Recent Errors</p>
|
||||||
|
<p className="text-lg font-semibold">{health?.recentErrors || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Active Users</p>
|
||||||
|
<p className="text-lg font-semibold">{health?.activeUsers || 0}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Detailed Stats Row */}
|
</CardContent>
|
||||||
<div className="p-6 grid grid-cols-3 gap-8 bg-gray-50/30">
|
</Card>
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Paid Invoices
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-black text-green-600">
|
|
||||||
{metrics?.paidInvoices || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-left border-l pl-8">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Pending Invoices
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-black text-orange-500">
|
|
||||||
{metrics?.pendingInvoices || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-left border-l pl-8">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Overdue Invoices
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-black text-red-500">
|
|
||||||
{metrics?.overdueInvoices || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Recent Proforma Requests (Full Width) */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">
|
|
||||||
Recent Proforma Requests
|
|
||||||
</h2>
|
|
||||||
<div className="border overflow-hidden rounded-none">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Title
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Category
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Deadline
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y">
|
|
||||||
{proformaRequests?.data && proformaRequests.data.length > 0 ? (
|
|
||||||
proformaRequests.data.map((request) => (
|
|
||||||
<tr
|
|
||||||
key={request.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-5">
|
|
||||||
<div className="text-sm font-bold text-gray-900 uppercase tracking-tight">
|
|
||||||
{request.title}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-5 text-sm font-medium text-gray-600">
|
|
||||||
{request.category}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-none border-gray-200 font-bold text-[10px] uppercase tracking-widest"
|
|
||||||
>
|
|
||||||
{request.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-5 text-sm text-gray-500 font-medium">
|
|
||||||
{format(
|
|
||||||
new Date(request.submissionDeadline),
|
|
||||||
"MMM dd, yyyy",
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
No proforma requests recorded.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Pending Verification (Full Width) */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">
|
|
||||||
Pending Verification
|
|
||||||
</h2>
|
|
||||||
<div className="border overflow-hidden rounded-none">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Invoice Number
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Customer
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
||||||
Issue Date
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y">
|
|
||||||
{scannedInvoices && scannedInvoices.length > 0 ? (
|
|
||||||
scannedInvoices.map((invoice) => (
|
|
||||||
<tr
|
|
||||||
key={invoice.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-5 text-sm font-bold text-gray-900">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-5 text-sm text-gray-600">
|
|
||||||
{invoice.customerName}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-5 text-sm font-bold text-gray-900">
|
|
||||||
{formatCurrency(invoice.amount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-5 text-sm text-gray-500">
|
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
|
||||||
>
|
|
||||||
No invoices are currently pending verification.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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 { ArrowLeft, ExternalLink, Loader2 } from "lucide-react"
|
|
||||||
import { emailService } from "@/services"
|
|
||||||
|
|
||||||
export default function EmailTemplatePreviewPage() {
|
|
||||||
const { key } = useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const { data: templatesData } = useQuery({
|
|
||||||
queryKey: ['admin', 'email-templates'],
|
|
||||||
queryFn: () => emailService.listPreviewTemplates(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: preview, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['admin', 'email-templates', key, 'preview'],
|
|
||||||
queryFn: () => emailService.getPreviewJson(key!),
|
|
||||||
enabled: !!key,
|
|
||||||
})
|
|
||||||
|
|
||||||
const templateMeta = templatesData?.templates.find((t) => t.key === key)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
Rendering preview...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !preview) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Button variant="ghost" onClick={() => navigate('/admin/email-templates')}>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back to Templates
|
|
||||||
</Button>
|
|
||||||
<div className="text-center py-16 text-destructive">
|
|
||||||
Failed to load email preview.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/email-templates')}>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold">{templateMeta?.name ?? preview.templateKey}</h2>
|
|
||||||
<p className="text-muted-foreground mt-1">{templateMeta?.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a
|
|
||||||
href={emailService.getPreviewHtmlUrl(key!)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
Open in New Tab
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<CardTitle className="text-base">Subject</CardTitle>
|
|
||||||
<Badge variant="secondary">{preview.subject}</Badge>
|
|
||||||
<Badge variant="outline" className="font-mono">{preview.templateKey}</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="border rounded-lg overflow-hidden bg-white">
|
|
||||||
<iframe
|
|
||||||
title={`Email preview: ${preview.templateKey}`}
|
|
||||||
srcDoc={preview.html}
|
|
||||||
className="w-full min-h-[700px] border-0"
|
|
||||||
sandbox="allow-same-origin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Mail, Eye, Loader2 } from "lucide-react"
|
|
||||||
import { emailService } from "@/services"
|
|
||||||
|
|
||||||
export default function EmailTemplatesPage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['admin', 'email-templates'],
|
|
||||||
queryFn: () => emailService.listPreviewTemplates(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
Loading email templates...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-16 text-destructive">
|
|
||||||
Failed to load email templates.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const templates = data?.templates ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold">Email Templates</h2>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Preview React Email templates with sample data and company branding.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
||||||
{templates.map((template) => (
|
|
||||||
<Card
|
|
||||||
key={template.key}
|
|
||||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
|
||||||
onClick={() => navigate(`/admin/email-templates/${template.key}`)}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="p-2 rounded-md bg-primary/10">
|
|
||||||
<Mail className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">{template.name}</CardTitle>
|
|
||||||
<Badge variant="secondary" className="mt-1 font-mono text-xs">
|
|
||||||
{template.key}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardDescription className="line-clamp-2">{template.description}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
|
||||||
Subject: {template.defaultSubject}
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
|
||||||
<Eye className="w-4 h-4 mr-2" />
|
|
||||||
Preview Template
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +1,177 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import {
|
import { Badge } from "@/components/ui/badge"
|
||||||
AlertCircle,
|
import { AlertCircle, CheckCircle, XCircle, Users } from "lucide-react"
|
||||||
CheckCircle,
|
import { systemService } from "@/services"
|
||||||
XCircle,
|
|
||||||
Users,
|
|
||||||
Zap,
|
|
||||||
Database,
|
|
||||||
Activity,
|
|
||||||
Cpu,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { systemService } from "@/services";
|
|
||||||
|
|
||||||
export default function HealthPage() {
|
export default function HealthPage() {
|
||||||
const { data: health, isLoading: healthLoading } = useQuery({
|
const { data: health, isLoading: healthLoading } = useQuery({
|
||||||
queryKey: ["admin", "system", "health"],
|
queryKey: ['admin', 'system', 'health'],
|
||||||
queryFn: () => systemService.getHealth(),
|
queryFn: () => systemService.getHealth(),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: systemInfo, isLoading: infoLoading } = useQuery({
|
const { data: systemInfo, isLoading: infoLoading } = useQuery({
|
||||||
queryKey: ["admin", "system", "info"],
|
queryKey: ['admin', 'system', 'info'],
|
||||||
queryFn: () => systemService.getSystemInfo(),
|
queryFn: () => systemService.getSystemInfo(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const getStatusIcon = (status?: string) => {
|
const getStatusIcon = (status?: string) => {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case "healthy":
|
case 'healthy':
|
||||||
case "connected":
|
case 'connected':
|
||||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
return <CheckCircle className="w-5 h-5 text-green-500" />
|
||||||
case "degraded":
|
case 'degraded':
|
||||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
|
return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||||
case "disconnected":
|
case 'disconnected':
|
||||||
case "down":
|
case 'down':
|
||||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
return <XCircle className="w-5 h-5 text-red-500" />
|
||||||
default:
|
default:
|
||||||
return <AlertCircle className="w-5 h-5 text-gray-500" />;
|
return <AlertCircle className="w-5 h-5 text-gray-500" />
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatUptime = (seconds: number) => {
|
const formatUptime = (seconds: number) => {
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400)
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
return `${days}d ${hours}h ${minutes}m`;
|
return `${days}d ${hours}h ${minutes}m`
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return '0 Bytes'
|
||||||
const k = 1024;
|
const k = 1024
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-3xl font-bold">System Health</h2>
|
<h2 className="text-3xl font-bold">System Health</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{[
|
<Card>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Core Services",
|
<CardTitle className="flex items-center gap-2">
|
||||||
value: health?.status,
|
{getStatusIcon(health?.status)}
|
||||||
icon: Zap,
|
System Status
|
||||||
color: "text-blue-600 bg-blue-50/50 border-blue-100/50",
|
</CardTitle>
|
||||||
},
|
</CardHeader>
|
||||||
{
|
<CardContent>
|
||||||
label: "Database",
|
{healthLoading ? (
|
||||||
value: health?.database,
|
<div>Loading...</div>
|
||||||
icon: Database,
|
) : (
|
||||||
color: "text-emerald-600 bg-emerald-50/50 border-emerald-100/50",
|
<Badge variant={health?.status === 'healthy' ? 'default' : 'destructive'}>
|
||||||
},
|
{health?.status || 'Unknown'}
|
||||||
{
|
</Badge>
|
||||||
label: "Critical Errors",
|
)}
|
||||||
value: health?.recentErrors || 0,
|
</CardContent>
|
||||||
icon: Activity,
|
</Card>
|
||||||
color: "text-rose-600 bg-rose-50/50 border-rose-100/50",
|
|
||||||
},
|
<Card>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Active Sessions",
|
<CardTitle className="flex items-center gap-2">
|
||||||
value: health?.activeUsers || 0,
|
{getStatusIcon(health?.database)}
|
||||||
icon: Users,
|
Database
|
||||||
color: "text-purple-600 bg-purple-50/50 border-purple-100/50",
|
</CardTitle>
|
||||||
},
|
</CardHeader>
|
||||||
].map((metric) => (
|
<CardContent>
|
||||||
<Card
|
{healthLoading ? (
|
||||||
key={metric.label}
|
<div>Loading...</div>
|
||||||
className="border-slate-200/60 shadow-sm hover:shadow-md transition-shadow rounded-none bg-white overflow-hidden group"
|
) : (
|
||||||
>
|
<Badge variant={health?.database === 'connected' ? 'default' : 'destructive'}>
|
||||||
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
|
{health?.database || 'Unknown'}
|
||||||
<CardTitle className="text-[10px] font-bold uppercase tracking-widest text-slate-400 flex items-center justify-between">
|
</Badge>
|
||||||
{metric.label}
|
)}
|
||||||
<metric.icon className="w-3.5 h-3.5 text-slate-300 group-hover:text-slate-400 transition-colors" />
|
</CardContent>
|
||||||
</CardTitle>
|
</Card>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
<Card>
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader>
|
||||||
<span className="text-2xl font-black text-slate-900 tracking-tighter">
|
<CardTitle className="flex items-center gap-2">
|
||||||
{healthLoading
|
<AlertCircle className="w-5 h-5" />
|
||||||
? "..."
|
Recent Errors
|
||||||
: metric.label === "Core Services" ||
|
</CardTitle>
|
||||||
metric.label === "Database"
|
</CardHeader>
|
||||||
? (metric.value as string)?.toUpperCase()
|
<CardContent>
|
||||||
: metric.value}
|
{healthLoading ? (
|
||||||
</span>
|
<div>Loading...</div>
|
||||||
{typeof metric.value === "string" &&
|
) : (
|
||||||
getStatusIcon(metric.value)}
|
<div className="text-2xl font-bold">{health?.recentErrors || 0}</div>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Active Users
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{healthLoading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{health?.activeUsers || 0}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{systemInfo && (
|
{systemInfo && (
|
||||||
<Card className="border-slate-200/60 shadow-sm rounded-none overflow-hidden">
|
<Card>
|
||||||
<CardHeader className="border-b border-slate-100 bg-slate-50/30 flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<div>
|
<CardTitle>System Information</CardTitle>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-slate-900">
|
|
||||||
Environment Specifications
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-tighter mt-0.5">
|
|
||||||
Hardware & Infrastructure Metadata
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Cpu className="w-4 h-4 text-slate-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
{infoLoading ? (
|
{infoLoading ? (
|
||||||
<div className="text-center py-16 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
|
<div className="text-center py-8">Loading system info...</div>
|
||||||
Interrogating backend environment...
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 divide-x divide-y md:divide-y-0 border-b border-slate-100">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[
|
<div>
|
||||||
{ label: "Node.js Version", value: systemInfo.nodeVersion },
|
<p className="text-sm text-muted-foreground">Node.js Version</p>
|
||||||
{
|
<p className="font-medium">{systemInfo.nodeVersion}</p>
|
||||||
label: "Platform Layer",
|
</div>
|
||||||
value: systemInfo.platform || "N/A",
|
<div>
|
||||||
},
|
<p className="text-sm text-muted-foreground">Platform</p>
|
||||||
{
|
<p className="font-medium">{systemInfo.platform || 'N/A'}</p>
|
||||||
label: "Architecture",
|
</div>
|
||||||
value: systemInfo.architecture || "N/A",
|
<div>
|
||||||
},
|
<p className="text-sm text-muted-foreground">Architecture</p>
|
||||||
{
|
<p className="font-medium">{systemInfo.architecture || 'N/A'}</p>
|
||||||
label: "Temporal Uptime",
|
</div>
|
||||||
value: formatUptime(systemInfo.uptime || 0),
|
<div>
|
||||||
},
|
<p className="text-sm text-muted-foreground">Uptime</p>
|
||||||
].map((item) => (
|
<p className="font-medium">{formatUptime(systemInfo.uptime || 0)}</p>
|
||||||
<div
|
</div>
|
||||||
key={item.label}
|
<div>
|
||||||
className="p-6 transition-colors hover:bg-slate-50/50"
|
<p className="text-sm text-muted-foreground">Environment</p>
|
||||||
>
|
<p className="font-medium">{systemInfo.env || systemInfo.environment}</p>
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">
|
</div>
|
||||||
{item.label}
|
<div>
|
||||||
</p>
|
<p className="text-sm text-muted-foreground">Memory Usage</p>
|
||||||
<p className="text-lg font-black text-slate-900 tracking-tighter">
|
<p className="font-medium">
|
||||||
{item.value}
|
{formatBytes(systemInfo.memory?.used || 0)} / {formatBytes(systemInfo.memory?.total || 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">CPU Cores</p>
|
||||||
)}
|
<p className="font-medium">{systemInfo.cpu?.cores || 'N/A'}</p>
|
||||||
{!infoLoading && (
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 divide-x">
|
<div>
|
||||||
{[
|
<p className="text-sm text-muted-foreground">Load Average</p>
|
||||||
{
|
<p className="font-medium">
|
||||||
label: "Environment Scope",
|
{systemInfo.cpu?.loadAverage?.map((load: number) => load.toFixed(2)).join(', ') || 'N/A'}
|
||||||
value: systemInfo.env || systemInfo.environment,
|
</p>
|
||||||
},
|
</div>
|
||||||
{
|
|
||||||
label: "Aggregate Memory",
|
|
||||||
value: `${formatBytes(systemInfo.memory?.used || 0)} / ${formatBytes(systemInfo.memory?.total || 0)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Physical Cores",
|
|
||||||
value: systemInfo.cpu?.cores || "N/A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Traffic Index (1m/5m/15m)",
|
|
||||||
value:
|
|
||||||
systemInfo.cpu?.loadAverage
|
|
||||||
?.map((load: number) => load.toFixed(2))
|
|
||||||
.join(" • ") || "N/A",
|
|
||||||
},
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="p-6 transition-colors hover:bg-slate-50/50"
|
|
||||||
>
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-black text-slate-900 tracking-tighter">
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,820 +0,0 @@
|
||||||
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,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
} 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 { invoiceService } from "@/services";
|
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { Invoice, InvoiceItem } from "@/services/invoice.service";
|
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
|
||||||
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
|
|
||||||
useAdminRole();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const [editingInvoice, setEditingInvoice] = useState<Invoice | null>(null);
|
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Form State
|
|
||||||
const [formData, setFormData] = useState<Partial<Invoice>>({
|
|
||||||
invoiceNumber: "",
|
|
||||||
customerName: "",
|
|
||||||
customerEmail: "",
|
|
||||||
customerPhone: "",
|
|
||||||
amount: 0,
|
|
||||||
currency: "USD",
|
|
||||||
type: "SALES",
|
|
||||||
status: "DRAFT",
|
|
||||||
issueDate: new Date().toISOString().split("T")[0],
|
|
||||||
dueDate: new Date().toISOString().split("T")[0],
|
|
||||||
description: "",
|
|
||||||
notes: "",
|
|
||||||
taxAmount: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
items: [] as InvoiceItem[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: invoicesData, isLoading } = useQuery({
|
|
||||||
queryKey: ["admin", "invoices", page, search],
|
|
||||||
queryFn: () =>
|
|
||||||
invoiceService.getInvoices({
|
|
||||||
page,
|
|
||||||
limit: 10,
|
|
||||||
search: search || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: any) => invoiceService.createInvoice(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice created successfully");
|
|
||||||
setIsModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "invoices"] });
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(
|
|
||||||
err.response?.data?.message?.[0] || "Failed to create invoice",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
|
||||||
invoiceService.updateInvoice(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice updated successfully");
|
|
||||||
setIsModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "invoices"] });
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(
|
|
||||||
err.response?.data?.message?.[0] || "Failed to update invoice",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => invoiceService.deleteInvoice(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice deleted successfully");
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "invoices"] });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
|
||||||
setEditingInvoice(null);
|
|
||||||
setFormData({
|
|
||||||
invoiceNumber: `INV-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`,
|
|
||||||
customerName: "",
|
|
||||||
customerEmail: "",
|
|
||||||
customerPhone: "",
|
|
||||||
amount: 0,
|
|
||||||
currency: "USD",
|
|
||||||
type: "SALES",
|
|
||||||
status: "DRAFT",
|
|
||||||
issueDate: new Date().toISOString().split("T")[0],
|
|
||||||
dueDate: new Date().toISOString().split("T")[0],
|
|
||||||
description: "",
|
|
||||||
notes: "",
|
|
||||||
taxAmount: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenEdit = (invoice: Invoice) => {
|
|
||||||
setEditingInvoice(invoice);
|
|
||||||
setFormData({
|
|
||||||
...invoice,
|
|
||||||
issueDate: new Date(invoice.issueDate).toISOString().split("T")[0],
|
|
||||||
dueDate: new Date(invoice.dueDate).toISOString().split("T")[0],
|
|
||||||
});
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (editingInvoice) {
|
|
||||||
updateMutation.mutate({ id: editingInvoice.id, data: formData });
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(formData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddItem = () => {
|
|
||||||
const newItem: InvoiceItem = {
|
|
||||||
id: Math.random().toString(36).substring(7),
|
|
||||||
description: "",
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 0,
|
|
||||||
total: 0,
|
|
||||||
};
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
items: [...(formData.items || []), newItem],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTotals = (
|
|
||||||
items: InvoiceItem[],
|
|
||||||
tax: number,
|
|
||||||
discount: number,
|
|
||||||
) => {
|
|
||||||
const subtotal = items.reduce(
|
|
||||||
(acc: number, item: InvoiceItem) => acc + item.total,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return subtotal + tax - discount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateItem = (
|
|
||||||
index: number,
|
|
||||||
field: keyof InvoiceItem,
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
const newItems = [...(formData.items || [])];
|
|
||||||
newItems[index] = { ...newItems[index], [field]: value } as InvoiceItem;
|
|
||||||
|
|
||||||
if (field === "quantity" || field === "unitPrice") {
|
|
||||||
newItems[index].total =
|
|
||||||
Number(newItems[index].quantity) * Number(newItems[index].unitPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAmount = calculateTotals(
|
|
||||||
newItems,
|
|
||||||
formData.taxAmount || 0,
|
|
||||||
formData.discountAmount || 0,
|
|
||||||
);
|
|
||||||
setFormData({ ...formData, items: newItems, amount: newAmount });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveItem = (index: number) => {
|
|
||||||
const newItems = (formData.items || []).filter(
|
|
||||||
(_, i: number) => i !== index,
|
|
||||||
);
|
|
||||||
const newAmount = calculateTotals(
|
|
||||||
newItems,
|
|
||||||
formData.taxAmount || 0,
|
|
||||||
formData.discountAmount || 0,
|
|
||||||
);
|
|
||||||
setFormData({ ...formData, items: newItems, amount: newAmount });
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "PAID":
|
|
||||||
return "text-emerald-600 bg-emerald-50 border-emerald-100";
|
|
||||||
case "PENDING":
|
|
||||||
return "text-amber-600 bg-amber-50 border-amber-100";
|
|
||||||
case "OVERDUE":
|
|
||||||
return "text-rose-600 bg-rose-50 border-rose-100";
|
|
||||||
case "CANCELLED":
|
|
||||||
return "text-gray-600 bg-gray-50 border-gray-100";
|
|
||||||
default:
|
|
||||||
return "text-slate-600 bg-slate-50 border-slate-100";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Invoices
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Manage sales and purchase invoices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canCreateBusinessData && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleOpenCreate}
|
|
||||||
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Invoice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Invoice Ledger
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
|
||||||
placeholder="Search customer or #..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Invoice #
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Customer
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={7}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Synchronizing ledger data...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : invoicesData?.data && invoicesData.data.length > 0 ? (
|
|
||||||
invoicesData.data.map((invoice) => (
|
|
||||||
<tr
|
|
||||||
key={invoice.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-gray-900">
|
|
||||||
{invoice.customerName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400">
|
|
||||||
{invoice.customerEmail}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-1.5 py-0.5 text-[9px] font-black uppercase tracking-tighter border",
|
|
||||||
invoice.type === "SALES"
|
|
||||||
? "text-blue-600 border-blue-100 bg-blue-50/30"
|
|
||||||
: "text-purple-600 border-purple-100 bg-purple-50/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{invoice.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">
|
|
||||||
{formatCurrency(invoice.amount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
|
||||||
getStatusColor(invoice.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{invoice.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{canEditBusinessData && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => handleOpenEdit(invoice)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canDeleteBusinessData && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-rose-600"
|
|
||||||
onClick={() => {
|
|
||||||
setInvoiceToDelete(invoice.id);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!canEditBusinessData && !canDeleteBusinessData && (
|
|
||||||
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
|
||||||
View Only
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={7}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
|
||||||
>
|
|
||||||
No invoices found in ledger.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
{invoicesData?.meta && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Page {invoicesData.meta.page} of {invoicesData.meta.totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!invoicesData.meta.hasPreviousPage}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!invoicesData.meta.hasNextPage}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
|
||||||
{editingInvoice ? "Update Invoice" : "Create New Invoice"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Configure administrative ledger entry.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Invoice Number
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.invoiceNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
invoiceNumber: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Customer Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.customerName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, customerName: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Customer Email
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={formData.customerEmail}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerEmail: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Type
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.type}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, type: v as any })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="SALES">SALES</SelectItem>
|
|
||||||
<SelectItem value="PURCHASE">PURCHASE</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Status
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, status: v as any })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="DRAFT">DRAFT</SelectItem>
|
|
||||||
<SelectItem value="PENDING">PENDING</SelectItem>
|
|
||||||
<SelectItem value="PAID">PAID</SelectItem>
|
|
||||||
<SelectItem value="OVERDUE">OVERDUE</SelectItem>
|
|
||||||
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Issue Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.issueDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, issueDate: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Due Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.dueDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, dueDate: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, description: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Tax Amount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.taxAmount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const tax = parseFloat(e.target.value) || 0;
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
taxAmount: tax,
|
|
||||||
amount: calculateTotals(
|
|
||||||
formData.items || [],
|
|
||||||
tax,
|
|
||||||
formData.discountAmount || 0,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Discount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.discountAmount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const discount = parseFloat(e.target.value) || 0;
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
discountAmount: discount,
|
|
||||||
amount: calculateTotals(
|
|
||||||
formData.items || [],
|
|
||||||
formData.taxAmount || 0,
|
|
||||||
discount,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line Items */}
|
|
||||||
<div className="border-t pt-6 mt-2">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
|
|
||||||
Line Items
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddItem}
|
|
||||||
className="rounded-none h-7 border-slate-200 text-[9px] font-bold uppercase"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 mr-1" /> Add Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{formData.items?.map((item: InvoiceItem, idx: number) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex gap-4 items-end bg-slate-50/50 p-3 border border-slate-100"
|
|
||||||
>
|
|
||||||
<div className="flex-1 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Service Description
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(idx, "description", e.target.value)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-20 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Qty
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(
|
|
||||||
idx,
|
|
||||||
"quantity",
|
|
||||||
parseInt(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-28 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Unit Price
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={item.unitPrice}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(
|
|
||||||
idx,
|
|
||||||
"unitPrice",
|
|
||||||
parseFloat(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-24 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Total
|
|
||||||
</Label>
|
|
||||||
<div className="h-8 flex items-center px-3 bg-white border border-slate-100 text-xs font-bold">
|
|
||||||
{formatCurrency(item.total)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleRemoveItem(idx)}
|
|
||||||
className="h-8 w-8 text-slate-300 hover:text-rose-500"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="border-t pt-6 mt-6">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Total Invoice Amount
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-black tracking-tighter text-slate-900">
|
|
||||||
{formatCurrency(formData.amount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="rounded-none uppercase font-bold text-[10px]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
createMutation.isPending || updateMutation.isPending
|
|
||||||
}
|
|
||||||
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
|
|
||||||
>
|
|
||||||
{createMutation.isPending || updateMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : editingInvoice ? (
|
|
||||||
"Update Ledger"
|
|
||||||
) : (
|
|
||||||
"Commit to Ledger"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
|
||||||
<DialogContent className="rounded-none">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
|
||||||
Delete Ledger Entry?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
|
|
||||||
This action is permanent and cannot be reversed.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4 text-sm text-slate-600">
|
|
||||||
Are you sure you want to delete this invoice record? All associated
|
|
||||||
line item data will be purged.
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsDeleteModalOpen(false)}
|
|
||||||
className="rounded-none uppercase font-bold text-[10px]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
invoiceToDelete && deleteMutation.mutate(invoiceToDelete)
|
|
||||||
}
|
|
||||||
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Confirm Deletion"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,751 +0,0 @@
|
||||||
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,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
FileText,
|
|
||||||
} 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 { invoiceService } from "@/services";
|
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { Proforma, InvoiceItem } from "@/services/invoice.service";
|
|
||||||
|
|
||||||
export default function ProformaPage() {
|
|
||||||
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
|
|
||||||
useAdminRole();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const [editingProforma, setEditingProforma] = useState<Proforma | null>(null);
|
|
||||||
const [proformaToDelete, setProformaToDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Form State
|
|
||||||
const [formData, setFormData] = useState<Partial<Proforma>>({
|
|
||||||
proformaNumber: "",
|
|
||||||
customerName: "",
|
|
||||||
customerEmail: "",
|
|
||||||
customerPhone: "",
|
|
||||||
amount: 0,
|
|
||||||
currency: "USD",
|
|
||||||
issueDate: new Date().toISOString().split("T")[0],
|
|
||||||
dueDate: new Date().toISOString().split("T")[0],
|
|
||||||
description: "",
|
|
||||||
notes: "",
|
|
||||||
taxAmount: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
items: [] as InvoiceItem[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: proformaData, isLoading } = useQuery({
|
|
||||||
queryKey: ["admin", "proforma", page, search],
|
|
||||||
queryFn: () =>
|
|
||||||
invoiceService.getProformas({
|
|
||||||
page,
|
|
||||||
limit: 10,
|
|
||||||
search: search || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: any) => invoiceService.createProforma(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Proforma invoice created");
|
|
||||||
setIsModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "proforma"] });
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(
|
|
||||||
err.response?.data?.message?.[0] || "Failed to create proforma",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
|
||||||
invoiceService.updateProforma(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Proforma updated");
|
|
||||||
setIsModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "proforma"] });
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(
|
|
||||||
err.response?.data?.message?.[0] || "Failed to update proforma",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => invoiceService.deleteProforma(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Proforma deleted");
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "proforma"] });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to delete proforma");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
|
||||||
setEditingProforma(null);
|
|
||||||
setFormData({
|
|
||||||
proformaNumber: `PRO-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`,
|
|
||||||
customerName: "",
|
|
||||||
customerEmail: "",
|
|
||||||
customerPhone: "",
|
|
||||||
amount: 0,
|
|
||||||
currency: "USD",
|
|
||||||
issueDate: new Date().toISOString().split("T")[0],
|
|
||||||
dueDate: new Date().toISOString().split("T")[0],
|
|
||||||
description: "",
|
|
||||||
notes: "",
|
|
||||||
taxAmount: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenEdit = (item: Proforma) => {
|
|
||||||
setEditingProforma(item);
|
|
||||||
setFormData({
|
|
||||||
...item,
|
|
||||||
issueDate: new Date(item.issueDate).toISOString().split("T")[0],
|
|
||||||
dueDate: new Date(item.dueDate).toISOString().split("T")[0],
|
|
||||||
});
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (editingProforma) {
|
|
||||||
updateMutation.mutate({ id: editingProforma.id, data: formData });
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(formData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTotals = (
|
|
||||||
items: InvoiceItem[],
|
|
||||||
tax: number,
|
|
||||||
discount: number,
|
|
||||||
) => {
|
|
||||||
const subtotal = items.reduce(
|
|
||||||
(acc: number, item: InvoiceItem) => acc + item.total,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return subtotal + tax - discount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddItem = () => {
|
|
||||||
const newItem: InvoiceItem = {
|
|
||||||
id: Math.random().toString(36).substring(7),
|
|
||||||
description: "",
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: 0,
|
|
||||||
total: 0,
|
|
||||||
};
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
items: [...(formData.items || []), newItem],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateItem = (
|
|
||||||
index: number,
|
|
||||||
field: keyof InvoiceItem,
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
const newItems = [...(formData.items || [])];
|
|
||||||
newItems[index] = { ...newItems[index], [field]: value } as InvoiceItem;
|
|
||||||
|
|
||||||
if (field === "quantity" || field === "unitPrice") {
|
|
||||||
newItems[index].total =
|
|
||||||
Number(newItems[index].quantity) * Number(newItems[index].unitPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAmount = calculateTotals(
|
|
||||||
newItems,
|
|
||||||
formData.taxAmount || 0,
|
|
||||||
formData.discountAmount || 0,
|
|
||||||
);
|
|
||||||
setFormData({ ...formData, items: newItems, amount: newAmount });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveItem = (index: number) => {
|
|
||||||
const newItems = (formData.items || []).filter(
|
|
||||||
(_, i: number) => i !== index,
|
|
||||||
);
|
|
||||||
const newAmount = calculateTotals(
|
|
||||||
newItems,
|
|
||||||
formData.taxAmount || 0,
|
|
||||||
formData.discountAmount || 0,
|
|
||||||
);
|
|
||||||
setFormData({ ...formData, items: newItems, amount: newAmount });
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Proforma Invoices
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Manage draft and preliminary invoices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canCreateBusinessData && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleOpenCreate}
|
|
||||||
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Proforma
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Proforma Registry
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
|
||||||
placeholder="Search Customer..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Proforma #
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Customer
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Issue Date
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Retrieving proforma records...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : proformaData?.data && proformaData.data.length > 0 ? (
|
|
||||||
proformaData.data.map((item) => (
|
|
||||||
<tr
|
|
||||||
key={item.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter flex items-center gap-2">
|
|
||||||
<FileText className="w-3.5 h-3.5 text-gray-400" />
|
|
||||||
{item.proformaNumber}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-gray-900">
|
|
||||||
{item.customerName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400">
|
|
||||||
{item.customerEmail}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(item.issueDate).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{canEditBusinessData && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => handleOpenEdit(item)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canDeleteBusinessData && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-rose-600"
|
|
||||||
onClick={() => {
|
|
||||||
setProformaToDelete(item.id);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!canEditBusinessData && !canDeleteBusinessData && (
|
|
||||||
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
|
||||||
View Only
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
|
||||||
>
|
|
||||||
No proforma records found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
{proformaData?.meta && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Page {proformaData.meta.page} of {proformaData.meta.totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!proformaData.meta.hasPreviousPage}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!proformaData.meta.hasNextPage}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
|
||||||
{editingProforma ? "Update Proforma" : "Create Proforma"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Execute a preliminary billing draft.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Proforma Number
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.proformaNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
proformaNumber: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Customer Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.customerName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, customerName: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Customer Email
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={formData.customerEmail}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerEmail: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Currency
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.currency}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, currency: v })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="USD">USD</SelectItem>
|
|
||||||
<SelectItem value="EUR">EUR</SelectItem>
|
|
||||||
<SelectItem value="GBP">GBP</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Issue Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.issueDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, issueDate: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Due Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.dueDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, dueDate: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, description: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Tax Amount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.taxAmount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const tax = parseFloat(e.target.value) || 0;
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
taxAmount: tax,
|
|
||||||
amount: calculateTotals(
|
|
||||||
formData.items || [],
|
|
||||||
tax,
|
|
||||||
formData.discountAmount || 0,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Discount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.discountAmount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const discount = parseFloat(e.target.value) || 0;
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
discountAmount: discount,
|
|
||||||
amount: calculateTotals(
|
|
||||||
formData.items || [],
|
|
||||||
formData.taxAmount || 0,
|
|
||||||
discount,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line Items */}
|
|
||||||
<div className="border-t pt-6 mt-2">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
|
|
||||||
Line Items
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddItem}
|
|
||||||
className="rounded-none h-7 border-slate-200 text-[9px] font-bold uppercase"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 mr-1" /> Add Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{formData.items?.map((item: InvoiceItem, idx: number) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex gap-4 items-end bg-slate-50/50 p-3 border border-slate-100"
|
|
||||||
>
|
|
||||||
<div className="flex-1 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Service Description
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(idx, "description", e.target.value)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-20 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Qty
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(
|
|
||||||
idx,
|
|
||||||
"quantity",
|
|
||||||
parseInt(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-28 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Unit Price
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={item.unitPrice}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateItem(
|
|
||||||
idx,
|
|
||||||
"unitPrice",
|
|
||||||
parseFloat(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-24 grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
|
||||||
Total
|
|
||||||
</Label>
|
|
||||||
<div className="h-8 flex items-center px-3 bg-white border border-slate-100 text-xs font-bold">
|
|
||||||
{formatCurrency(item.total)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleRemoveItem(idx)}
|
|
||||||
className="h-8 w-8 text-slate-300 hover:text-rose-500"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="border-t pt-6 mt-6">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Total Proforma Amount
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-black tracking-tighter text-slate-900">
|
|
||||||
{formatCurrency(formData.amount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="rounded-none uppercase font-bold text-[10px]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
createMutation.isPending || updateMutation.isPending
|
|
||||||
}
|
|
||||||
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
|
|
||||||
>
|
|
||||||
{createMutation.isPending || updateMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : editingProforma ? (
|
|
||||||
"Update Registry"
|
|
||||||
) : (
|
|
||||||
"Commit to Registry"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
|
||||||
<DialogContent className="rounded-none">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
|
||||||
Expunge Proforma Entry?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
|
|
||||||
This action is permanent and cannot be reversed.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4 text-sm text-slate-600">
|
|
||||||
Are you sure you want to delete this proforma record? All associated
|
|
||||||
line item data will be purged.
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsDeleteModalOpen(false)}
|
|
||||||
className="rounded-none uppercase font-bold text-[10px]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
proformaToDelete && deleteMutation.mutate(proformaToDelete)
|
|
||||||
}
|
|
||||||
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Confirm Deletion"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,550 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { NavLink } from "react-router-dom";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardHeader } 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,
|
|
||||||
LifeBuoy,
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
User as UserIcon,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ArrowRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { issueService } from "@/services";
|
|
||||||
import type { IssueStatus } from "@/services/issue.service";
|
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const getPriorityColor = (p: string) => {
|
|
||||||
switch (p) {
|
|
||||||
case "HIGH":
|
|
||||||
return "text-rose-600 bg-rose-50 border-rose-100";
|
|
||||||
case "MEDIUM":
|
|
||||||
return "text-amber-600 bg-amber-50 border-amber-100";
|
|
||||||
default:
|
|
||||||
return "text-slate-600 bg-slate-50 border-slate-100";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusConfig = (s: IssueStatus) => {
|
|
||||||
switch (s) {
|
|
||||||
case "OPEN":
|
|
||||||
return {
|
|
||||||
label: "Open Queue",
|
|
||||||
icon: AlertCircle,
|
|
||||||
color: "text-orange-600 bg-orange-50",
|
|
||||||
badge: "bg-orange-500",
|
|
||||||
};
|
|
||||||
case "IN_PROGRESS":
|
|
||||||
return {
|
|
||||||
label: "In Progress",
|
|
||||||
icon: Clock,
|
|
||||||
color: "text-blue-600 bg-blue-50",
|
|
||||||
badge: "bg-blue-500",
|
|
||||||
};
|
|
||||||
case "RESOLVED":
|
|
||||||
return {
|
|
||||||
label: "Resolved",
|
|
||||||
icon: CheckCircle2,
|
|
||||||
color: "text-emerald-600 bg-emerald-50",
|
|
||||||
badge: "bg-emerald-500",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
label: "Archived",
|
|
||||||
icon: CheckCircle2,
|
|
||||||
color: "text-slate-500 bg-slate-50",
|
|
||||||
badge: "bg-slate-400",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function IssuesPage() {
|
|
||||||
const { canEditBusinessData: canEdit } = useAdminRole();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [newIssue, setNewIssue] = useState({
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["admin", "issues", page, search],
|
|
||||||
queryFn: () =>
|
|
||||||
issueService.list({
|
|
||||||
page,
|
|
||||||
limit: 12,
|
|
||||||
search: search.trim() || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: () => issueService.create(newIssue),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Issue reported and logged into system");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] });
|
|
||||||
setOpen(false);
|
|
||||||
setNewIssue({ title: "", description: "", priority: "MEDIUM" });
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Critical failure during issue report creation"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
|
||||||
mutationFn: ({ id, status }: { id: string; status: IssueStatus }) =>
|
|
||||||
issueService.updateStatus(id, status),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Workflow status transition successful");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] });
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Transition refused by system"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-primary mb-1"></div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Issue Tracking
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="h-10 px-8 rounded-[6px] bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Report Issue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-8 border-b border-gray-100">
|
|
||||||
<NavLink
|
|
||||||
to="/admin/support/faq"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
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
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/admin/issues"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
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
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className=" border border-gray-100 rounded-[ 6px] overflow-hidden">
|
|
||||||
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
|
|
||||||
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
|
|
||||||
Support Request Queue
|
|
||||||
</h2>
|
|
||||||
<div className="relative group min-w-[320px]">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
|
||||||
<Input
|
|
||||||
className="pl-11 h-10 bg-slate-50 border-slate-200/60 rounded-[6px] text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
|
||||||
placeholder="Search ticket titles or reporter..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{error && (
|
|
||||||
<div className="m-8 p-2 bg-amber-50 border border-amber-100 rounded-[6px] flex items-center gap-4 text-amber-700">
|
|
||||||
<AlertCircle className="w-6 h-6 flex-shrink-0" />
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
Synchronization error. Local queue out of sync with{" "}
|
|
||||||
<code className="bg-amber-100/50 px-1.5 py-0.5 rounded leading-none">
|
|
||||||
GET /admin/issues
|
|
||||||
</code>{" "}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto min-h-[450px]">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-50/50 border-b border-slate-100">
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[35%]">
|
|
||||||
Objective / Details
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Reporter Profile
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Priority
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Timeline
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Governance
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<tr key={i} className="animate-pulse">
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="h-4 bg-slate-100 rounded-full w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-slate-50 rounded-full w-1/2"></div>
|
|
||||||
</td>
|
|
||||||
<td colSpan={4} className="px-8 py-6">
|
|
||||||
<div className="h-3 bg-slate-50 rounded-full w-32 ml-auto"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : data?.data?.length ? (
|
|
||||||
data.data.map((issue) => {
|
|
||||||
const status = getStatusConfig(issue.status);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={issue.id}
|
|
||||||
className="group hover:bg-slate-50/50 transition-colors align-top"
|
|
||||||
>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-sm font-black text-slate-900 tracking-tight leading-snug group-hover:text-primary transition-colors">
|
|
||||||
{issue.title}
|
|
||||||
</span>
|
|
||||||
<p className="text-[11px] text-slate-500 font-medium line-clamp-2 mt-1 leading-relaxed">
|
|
||||||
{issue.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-400">
|
|
||||||
<UserIcon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-bold text-slate-700">
|
|
||||||
{issue.reporterEmail}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="mt-1 text-[9px] font-black uppercase tracking-tighter rounded-md py-0 px-1.5 opacity-60"
|
|
||||||
>
|
|
||||||
{issue.reporterType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-2.5 py-1 text-[10px] font-black uppercase tracking-widest border shadow-none",
|
|
||||||
getPriorityColor(issue.priority),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{issue.priority}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">
|
|
||||||
Updated
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-semibold text-slate-700">
|
|
||||||
{new Date(
|
|
||||||
issue.updatedAt || issue.createdAt,
|
|
||||||
).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6 text-right">
|
|
||||||
{canEdit ? (
|
|
||||||
<Select
|
|
||||||
value={issue.status}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
statusMutation.mutate({
|
|
||||||
id: issue.id,
|
|
||||||
status: v as IssueStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10 w-[160px] rounded-xl text-[10px] font-black uppercase tracking-widest ml-auto bg-white border-slate-200/60 shadow-sm focus:ring-1 focus:ring-primary transition-all">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-1.5 h-1.5 rounded-full",
|
|
||||||
status.badge,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SelectValue />
|
|
||||||
</div>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-xl border-slate-100 shadow-xl">
|
|
||||||
<SelectItem
|
|
||||||
value="OPEN"
|
|
||||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
|
|
||||||
>
|
|
||||||
Open Queue
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="IN_PROGRESS"
|
|
||||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
|
|
||||||
>
|
|
||||||
In Progress
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="RESOLVED"
|
|
||||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary text-emerald-600"
|
|
||||||
>
|
|
||||||
Resolved
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="CLOSED"
|
|
||||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary italic opacity-50"
|
|
||||||
>
|
|
||||||
Archived
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 ml-auto w-fit px-3 py-1.5 rounded-xl border",
|
|
||||||
status.color,
|
|
||||||
"border-opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<status.icon className="w-3.5 h-3.5" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest">
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-8 py-24 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
|
||||||
<LifeBuoy className="w-16 h-16" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
|
||||||
Support empty
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium italic mt-1">
|
|
||||||
No active support report.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{data && data.totalPages > 1 && (
|
|
||||||
<div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
|
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest leading-none">
|
|
||||||
Page <span className="text-slate-900">{data.page}</span>{" "}
|
|
||||||
<span className="mx-1 opacity-20 text-[6px]">|</span>{" "}
|
|
||||||
<span className="text-slate-400 font-medium tracking-normal text-[11px] capitalize">
|
|
||||||
Showing {data.data.length} items
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center px-4 text-xs font-black text-primary bg-white rounded-xl shadow-sm border border-slate-200/50">
|
|
||||||
{data.page} / {data.totalPages}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
|
||||||
disabled={page >= data.totalPages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Report Modal */}
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="rounded-3xl max-w-lg p-0 border-none shadow-2xl overflow-hidden">
|
|
||||||
<div className="p-8 bg-slate-900 text-white">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="p-2 bg-white/10 rounded-xl">
|
|
||||||
<LifeBuoy className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
|
||||||
Report Anomaly
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
|
||||||
Capture <span className="text-primary NOT-italic">Issue</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
|
||||||
Document the system anomaly or customer friction point with
|
|
||||||
technical precision. Reports are immediately queued for triage.
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8 space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="iss-title"
|
|
||||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
|
||||||
>
|
|
||||||
Descriptive Headline
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="iss-title"
|
|
||||||
value={newIssue.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewIssue((n) => ({ ...n, title: e.target.value }))
|
|
||||||
}
|
|
||||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
|
|
||||||
placeholder="e.g. Authentication loop on mobile safari..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="iss-desc"
|
|
||||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
|
||||||
>
|
|
||||||
Technical Narrative
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
id="iss-desc"
|
|
||||||
value={newIssue.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewIssue((n) => ({ ...n, description: e.target.value }))
|
|
||||||
}
|
|
||||||
className="min-h-[120px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
|
|
||||||
placeholder="Steps to reproduce, error IDs, and environment context..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
|
||||||
Triage Priority
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={newIssue.priority}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setNewIssue((n) => ({
|
|
||||||
...n,
|
|
||||||
priority: v as typeof newIssue.priority,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-xs uppercase tracking-widest">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-xl">
|
|
||||||
<SelectItem
|
|
||||||
value="LOW"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
Low / Enhancement
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="MEDIUM"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
Medium / Routine
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="HIGH"
|
|
||||||
className="text-[10px] font-black uppercase text-rose-600"
|
|
||||||
>
|
|
||||||
High / Critical Anomaly
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
Discard
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-12 px-10 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-primary/20 transition-all active:scale-95"
|
|
||||||
disabled={
|
|
||||||
createMutation.isPending ||
|
|
||||||
!newIssue.title.trim() ||
|
|
||||||
!newIssue.description.trim()
|
|
||||||
}
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? "Queuing..." : "Commit Report"}
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +1,112 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { systemService } from "@/services";
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { toast } from "sonner";
|
import { systemService } from "@/services"
|
||||||
import { useState } from "react";
|
import { toast } from "sonner"
|
||||||
import type { ApiError } from "@/types/error.types";
|
import { useState } from "react"
|
||||||
import { cn } from "@/lib/utils";
|
import type { ApiError } from "@/types/error.types"
|
||||||
|
|
||||||
export default function MaintenancePage() {
|
export default function MaintenancePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
const { data: status, isLoading } = useQuery({
|
const { data: status, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "maintenance"],
|
queryKey: ['admin', 'maintenance'],
|
||||||
queryFn: () => systemService.getMaintenanceStatus(),
|
queryFn: () => systemService.getMaintenanceStatus(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const enableMutation = useMutation({
|
const enableMutation = useMutation({
|
||||||
mutationFn: (msg?: string) => systemService.enableMaintenance(msg),
|
mutationFn: (msg?: string) => systemService.enableMaintenance(msg),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "maintenance"] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
||||||
toast.success("Maintenance mode enabled");
|
toast.success("Maintenance mode enabled")
|
||||||
setMessage("");
|
setMessage("")
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to enable maintenance mode")
|
||||||
apiError.response?.data?.message || "Failed to enable maintenance mode",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
const disableMutation = useMutation({
|
||||||
mutationFn: () => systemService.disableMaintenance(),
|
mutationFn: () => systemService.disableMaintenance(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "maintenance"] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
||||||
toast.success("Maintenance mode disabled");
|
toast.success("Maintenance mode disabled")
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to disable maintenance mode")
|
||||||
apiError.response?.data?.message ||
|
|
||||||
"Failed to disable maintenance mode",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const handleToggle = (enabled: boolean) => {
|
const handleToggle = (enabled: boolean) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
enableMutation.mutate(message || undefined);
|
enableMutation.mutate(message || undefined)
|
||||||
} else {
|
} else {
|
||||||
disableMutation.mutate();
|
disableMutation.mutate()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const isEnabled = status?.status === "ACTIVE";
|
if (isLoading) {
|
||||||
|
return <div className="text-center py-8">Loading maintenance status...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEnabled = status?.status === 'ACTIVE'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Maintenance Mode
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Control public access to the platform.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest border rounded-none transition-colors",
|
|
||||||
isEnabled
|
|
||||||
? "bg-rose-50 text-rose-600 border-rose-100"
|
|
||||||
: "bg-emerald-50 text-emerald-600 border-emerald-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
System: {isEnabled ? "OFFLINE (MAINTENANCE)" : "ONLINE"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none max-w-2xl">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
Status Control
|
<CardTitle>Maintenance Status</CardTitle>
|
||||||
</CardTitle>
|
<Badge variant={isEnabled ? 'destructive' : 'default'}>
|
||||||
|
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-8 space-y-8">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-8">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<Label className="text-sm font-bold text-gray-900 uppercase tracking-tighter">
|
<Label>Maintenance Mode</Label>
|
||||||
Toggle Maintenance
|
<p className="text-sm text-muted-foreground">
|
||||||
</Label>
|
Enable maintenance mode to temporarily disable access to the platform
|
||||||
<p className="text-xs text-gray-400 leading-relaxed max-w-xs">
|
|
||||||
Activating this will redirect all non-admin traffic to the
|
|
||||||
maintenance landing page.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isEnabled}
|
checked={isEnabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
className="data-[state=checked]:bg-gray-900"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEnabled && (
|
{!isEnabled && (
|
||||||
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="message">Maintenance Message (Optional)</Label>
|
||||||
htmlFor="message"
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest text-gray-400"
|
|
||||||
>
|
|
||||||
Broadcast Message (Optional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="message"
|
id="message"
|
||||||
placeholder="We'll be back shortly..."
|
placeholder="Enter maintenance message..."
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
className="h-11 rounded-none border-gray-200 text-sm font-medium focus-visible:ring-gray-900 shadow-none"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This message will be displayed to users when maintenance mode is enabled
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEnabled && status?.message && (
|
{isEnabled && status?.message && (
|
||||||
<div className="p-4 bg-slate-50 border border-slate-100 rounded-none animate-in fade-in duration-300">
|
<div>
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
<Label>Current Message</Label>
|
||||||
Current Broadcast
|
<p className="text-sm mt-2">{status.message}</p>
|
||||||
</Label>
|
|
||||||
<p className="text-sm font-medium text-slate-900 mt-1">
|
|
||||||
{status.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
|
||||||
Verifying system status...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { Navigate } from "react-router-dom"
|
|
||||||
import { useMutation } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Bell, Mail, MessageSquare, Send } from "lucide-react"
|
|
||||||
import { notificationService } from "@/services"
|
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export default function NotificationBroadcastPage() {
|
|
||||||
const { canSendBroadcast } = useAdminRole()
|
|
||||||
const [title, setTitle] = useState("")
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
const [audience, setAudience] = useState<
|
|
||||||
"all_end_users" | "system_users_only" | "everyone_with_access"
|
|
||||||
>("all_end_users")
|
|
||||||
const [channels, setChannels] = useState({
|
|
||||||
push: true,
|
|
||||||
sms: false,
|
|
||||||
email: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
notificationService.sendBroadcast({
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
audience,
|
|
||||||
channels: (
|
|
||||||
[
|
|
||||||
channels.push && "push",
|
|
||||||
channels.sms && "sms",
|
|
||||||
channels.email && "email",
|
|
||||||
].filter(Boolean) as ("push" | "sms" | "email")[]
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Broadcast queued for delivery")
|
|
||||||
setTitle("")
|
|
||||||
setMessage("")
|
|
||||||
},
|
|
||||||
onError: () =>
|
|
||||||
toast.error(
|
|
||||||
"Could not send. Ensure POST /admin/notifications/broadcast exists.",
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!canSendBroadcast) {
|
|
||||||
return <Navigate to="/admin/dashboard" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleChannel = (key: keyof typeof channels) => {
|
|
||||||
setChannels((c) => ({ ...c, [key]: !c[key] }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelActive =
|
|
||||||
channels.push || channels.sms || channels.email
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 max-w-2xl mx-auto bg-white p-4 min-h-screen">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Send notification
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Super Admins and Admins can broadcast via push, SMS, and email.
|
|
||||||
Delivery depends on user preferences and channel configuration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Channels
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-6 space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleChannel("push")}
|
|
||||||
className={cn(
|
|
||||||
"rounded-none border p-4 text-left transition-colors",
|
|
||||||
channels.push
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-gray-200 opacity-70 hover:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Bell className="h-5 w-5 mb-2 text-gray-700" />
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider">
|
|
||||||
Push
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleChannel("sms")}
|
|
||||||
className={cn(
|
|
||||||
"rounded-none border p-4 text-left transition-colors",
|
|
||||||
channels.sms
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-gray-200 opacity-70 hover:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-5 w-5 mb-2 text-gray-700" />
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider">
|
|
||||||
SMS
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleChannel("email")}
|
|
||||||
className={cn(
|
|
||||||
"rounded-none border p-4 text-left transition-colors",
|
|
||||||
channels.email
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-gray-200 opacity-70 hover:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Mail className="h-5 w-5 mb-2 text-gray-700" />
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider">
|
|
||||||
Email
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Audience
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<Select
|
|
||||||
value={audience}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setAudience(
|
|
||||||
v as
|
|
||||||
| "all_end_users"
|
|
||||||
| "system_users_only"
|
|
||||||
| "everyone_with_access",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all_end_users">
|
|
||||||
All platform customers
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="system_users_only">
|
|
||||||
Panel users only (support & admins)
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="everyone_with_access">
|
|
||||||
Everyone with an account
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Message
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-6 space-y-4">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="bc-title">Title</Label>
|
|
||||||
<Input
|
|
||||||
id="bc-title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="rounded-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="bc-body">Body</Label>
|
|
||||||
<textarea
|
|
||||||
id="bc-body"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
className="flex min-h-[160px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="rounded-none gap-2 w-full sm:w-auto"
|
|
||||||
disabled={
|
|
||||||
mutation.isPending || !title.trim() || !message.trim() || !channelActive
|
|
||||||
}
|
|
||||||
onClick={() => mutation.mutate()}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
Send broadcast
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,844 +0,0 @@
|
||||||
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<any>({
|
|
||||||
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 (
|
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Payment Requests
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Manage outbound customer requests.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canCreateBusinessData && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Request Queue
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
|
||||||
placeholder="Search Customer..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Request #
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Customer
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Due Date
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{requestsLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium"
|
|
||||||
>
|
|
||||||
Loading requests...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : requestsData?.data && requestsData.data.length > 0 ? (
|
|
||||||
requestsData.data.map((request) => (
|
|
||||||
<tr key={request.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
|
|
||||||
{request.paymentRequestNumber}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-gray-900">
|
|
||||||
{request.customerName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400">
|
|
||||||
{request.customerEmail}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">
|
|
||||||
{formatCurrency(request.amount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(request.dueDate).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
|
||||||
getStatusColor(request.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{request.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
|
||||||
>
|
|
||||||
No records found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
{requestsData?.meta && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Page {requestsData.meta.page} of {requestsData.meta.totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!requestsData.meta.hasPreviousPage}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!requestsData.meta.hasNextPage}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0 rounded-none border-slate-200">
|
|
||||||
<form
|
|
||||||
onSubmit={handleCreate}
|
|
||||||
className="flex flex-col h-full bg-slate-50"
|
|
||||||
>
|
|
||||||
<DialogHeader className="p-8 pb-6 bg-white border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="px-2 py-0.5 bg-slate-900 text-white text-[9px] font-black uppercase tracking-widest">
|
|
||||||
Draft
|
|
||||||
</span>
|
|
||||||
<DialogTitle className="text-xl font-bold tracking-tight text-slate-900">
|
|
||||||
Issue Payment Request
|
|
||||||
</DialogTitle>
|
|
||||||
</div>
|
|
||||||
<DialogDescription className="text-xs font-medium text-slate-400">
|
|
||||||
Draft a formal financial request for outbound settlement.
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-8 space-y-10">
|
|
||||||
{/* Header Info */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Reference Number
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.paymentRequestNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
paymentRequestNumber: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 font-mono text-xs font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Issue Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.issueDate.split("T")[0]}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
issueDate: new Date(e.target.value).toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Due Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.dueDate.split("T")[0]}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
dueDate: new Date(e.target.value).toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs text-rose-600 font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
|
||||||
{/* Customer Details */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="w-1 h-4 bg-slate-900" />
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
|
||||||
Recipient Details
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Customer Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. Acme Corp"
|
|
||||||
value={formData.customerName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerName: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Email Address
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="billing@acme.com"
|
|
||||||
value={formData.customerEmail}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerEmail: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs text-slate-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Phone Number
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="+1 (555) 000-0000"
|
|
||||||
value={formData.customerPhone}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerPhone: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Customer ID (External)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="CUST-001"
|
|
||||||
value={formData.customerId}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerId: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Financials & Logic */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="w-1 h-4 bg-slate-900" />
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
|
||||||
Financial Basis
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Currency
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.currency}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, currency: v })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200 h-10 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-none">
|
|
||||||
<SelectItem value="USD">USD - Dollar</SelectItem>
|
|
||||||
<SelectItem value="ETB">ETB - Birr</SelectItem>
|
|
||||||
<SelectItem value="EUR">EUR - Euro</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="Service payment"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
description: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Tax Amount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.taxAmount}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
taxAmount: parseFloat(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
Discount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.discountAmount}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
discountAmount: parseFloat(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-10 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 pt-2">
|
|
||||||
<div className="bg-slate-900 p-4 flex flex-col items-end justify-center">
|
|
||||||
<span className="text-[8px] font-black uppercase tracking-widest text-slate-500 mb-1">
|
|
||||||
Estimated Total
|
|
||||||
</span>
|
|
||||||
<span className="text-2xl font-black text-white tabular-nums">
|
|
||||||
{formatCurrency(
|
|
||||||
formData.amount +
|
|
||||||
formData.taxAmount -
|
|
||||||
formData.discountAmount,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line Items */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ListOrdered className="w-4 h-4 text-slate-400" />
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
|
||||||
Line Items
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-[10px] font-black uppercase tracking-widest h-8 text-blue-600 hover:text-blue-700"
|
|
||||||
onClick={addItem}
|
|
||||||
>
|
|
||||||
+ Link Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="border border-slate-200 divide-y divide-slate-100 bg-white shadow-sm">
|
|
||||||
{formData.items.map((item: any, idx: number) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="p-4 grid grid-cols-12 gap-4 items-end group"
|
|
||||||
>
|
|
||||||
<div className="col-span-6 space-y-2">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleItemChange(
|
|
||||||
idx,
|
|
||||||
"description",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-[11px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 space-y-2">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Qty
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleItemChange(
|
|
||||||
idx,
|
|
||||||
"quantity",
|
|
||||||
parseInt(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 space-y-2">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Unit Price
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={item.unitPrice}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleItemChange(
|
|
||||||
idx,
|
|
||||||
"unitPrice",
|
|
||||||
parseFloat(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 flex items-center justify-end gap-2">
|
|
||||||
<span className="text-xs font-bold text-slate-900 min-w-16 text-right">
|
|
||||||
{formatCurrency(item.total)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-rose-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => removeItem(idx)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settlement Accounts */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Building2 className="w-4 h-4 text-slate-400" />
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
|
||||||
Settlement Accounts
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-[10px] font-black uppercase tracking-widest h-8 text-blue-600 hover:text-blue-700"
|
|
||||||
onClick={addAccount}
|
|
||||||
>
|
|
||||||
+ Add Target
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{formData.accounts.map((acc: any, idx: number) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="relative p-6 bg-white border border-slate-200 space-y-4 group"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-2 top-2 h-7 w-7 text-slate-300 hover:text-rose-600 opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={() => removeAccount(idx)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Bank Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={acc.bankName}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleAccountChange(
|
|
||||||
idx,
|
|
||||||
"bankName",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-[11px] font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Account Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={acc.accountName}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleAccountChange(
|
|
||||||
idx,
|
|
||||||
"accountName",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-[11px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Account #
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={acc.accountNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleAccountChange(
|
|
||||||
idx,
|
|
||||||
"accountNumber",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 h-8 text-[11px] font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Curr
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={acc.currency}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
handleAccountChange(idx, "currency", v)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200 h-8 text-[10px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-none">
|
|
||||||
<SelectItem value="USD">USD</SelectItem>
|
|
||||||
<SelectItem value="ETB">ETB</SelectItem>
|
|
||||||
<SelectItem value="EUR">EUR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 pt-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Administrative Notes
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Enter internal notes or customer-facing terms..."
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, notes: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 min-h-[80px] text-xs resize-none p-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<DialogFooter className="p-8 bg-white border-t border-slate-100 flex items-center justify-between sm:justify-between">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="rounded-none uppercase text-[10px] font-black tracking-widest text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
>
|
|
||||||
Discard
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
className="rounded-none bg-slate-900 hover:bg-black text-white px-12 h-11 uppercase text-[10px] font-black tracking-widest shadow-lg shadow-slate-200"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Authorize & Send Request"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,542 +0,0 @@
|
||||||
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<Payment | null>(null);
|
|
||||||
const [paymentToDelete, setPaymentToDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Form State
|
|
||||||
const [formData, setFormData] = useState<Partial<Payment>>({
|
|
||||||
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 (
|
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Payments
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">History of settled transactions.</p>
|
|
||||||
</div>
|
|
||||||
{canCreateBusinessData && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleOpenCreate}
|
|
||||||
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Log Payment
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Transaction History
|
|
||||||
</CardTitle>
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
|
||||||
placeholder="Search Transaction ID..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Transaction ID
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Sender
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Method
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{paymentsLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Synchronizing ledger...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : paymentsData?.data && paymentsData.data.length > 0 ? (
|
|
||||||
paymentsData.data.map((payment) => (
|
|
||||||
<tr key={payment.id} className="hover:bg-gray-50 group">
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
|
|
||||||
{payment.transactionId}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
|
||||||
{payment.senderName || "Unknown"}
|
|
||||||
{payment.isFlagged && (
|
|
||||||
<span className="ml-2 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-none text-[8px] font-black uppercase bg-red-50 text-red-600 border border-red-100 italic">
|
|
||||||
Flagged
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase">
|
|
||||||
{payment.paymentMethod}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">
|
|
||||||
{formatCurrency(payment.amount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(payment.paymentDate).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{canEditBusinessData && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => handleOpenEdit(payment)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canDeleteBusinessData && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-rose-600"
|
|
||||||
onClick={() => {
|
|
||||||
setPaymentToDelete(payment.id);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!canEditBusinessData && !canDeleteBusinessData && (
|
|
||||||
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
|
||||||
View Only
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
|
||||||
>
|
|
||||||
No records found in transaction history.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
{paymentsData?.meta && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Page {paymentsData.meta.page} of {paymentsData.meta.totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!paymentsData.meta.hasPreviousPage}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
disabled={!paymentsData.meta.hasNextPage}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<DialogContent className="max-w-2xl rounded-none">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
|
||||||
{editingPayment ? "Update Record" : "Log New Payment"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Official transaction record entry.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Transaction ID
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.transactionId}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
transactionId: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.amount}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
amount: parseFloat(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Currency
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.currency}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, currency: v })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="USD">USD</SelectItem>
|
|
||||||
<SelectItem value="EUR">EUR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Payment Date
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.paymentDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, paymentDate: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Payment Method
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.paymentMethod}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, paymentMethod: v })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none border-slate-200">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Bank Transfer">
|
|
||||||
Bank Transfer
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Credit Card">Credit Card</SelectItem>
|
|
||||||
<SelectItem value="Cash">Cash</SelectItem>
|
|
||||||
<SelectItem value="Mobile Money">Mobile Money</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Linked Invoice ID
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.invoiceId}
|
|
||||||
placeholder="Optional"
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, invoiceId: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Linked Invoice ID
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.invoiceId}
|
|
||||||
placeholder="Optional"
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, invoiceId: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 pb-6">
|
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
|
||||||
Notes / Internal Comments
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, notes: e.target.value })
|
|
||||||
}
|
|
||||||
className="rounded-none border-slate-200 min-h-[100px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="border-t pt-6">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
Ledger Entry Total
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-black tracking-tighter text-slate-900">
|
|
||||||
{formatCurrency(formData.amount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="rounded-none uppercase font-bold text-[10px]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
createMutation.isPending || updateMutation.isPending
|
|
||||||
}
|
|
||||||
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
|
|
||||||
>
|
|
||||||
{createMutation.isPending || updateMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : editingPayment ? (
|
|
||||||
"Update Ledger"
|
|
||||||
) : (
|
|
||||||
"Commit to Ledger"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
|
||||||
<DialogContent className="rounded-none">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
|
||||||
Expunge Payment Record?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
|
|
||||||
This action is permanent and cannot be reversed.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4 text-sm text-slate-600">
|
|
||||||
Are you sure you want to delete this payment record? This will
|
|
||||||
un-settle any linked invoices.
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsDeleteModalOpen(false)}
|
|
||||||
className="rounded-none uppercase font-bold text-[10px]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
paymentToDelete && deleteMutation.mutate(paymentToDelete)
|
|
||||||
}
|
|
||||||
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Confirm Deletion"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +1,102 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Ban, Key, Calendar, User, Zap } from "lucide-react";
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { securityService, type ApiKey } from "@/services";
|
import {
|
||||||
import { toast } from "sonner";
|
Table,
|
||||||
import { format } from "date-fns";
|
TableBody,
|
||||||
import type { ApiError } from "@/types/error.types";
|
TableCell,
|
||||||
import { cn } from "@/lib/utils";
|
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"
|
||||||
|
|
||||||
export default function ApiKeysPage() {
|
export default function ApiKeysPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: apiKeys, isLoading } = useQuery({
|
const { data: apiKeys, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "security", "api-keys"],
|
queryKey: ['admin', 'security', 'api-keys'],
|
||||||
queryFn: () => securityService.getAllApiKeys(),
|
queryFn: () => securityService.getAllApiKeys(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const revokeMutation = useMutation({
|
const revokeMutation = useMutation({
|
||||||
mutationFn: (id: string) => securityService.revokeApiKey(id),
|
mutationFn: (id: string) => securityService.revokeApiKey(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
|
||||||
queryKey: ["admin", "security", "api-keys"],
|
toast.success("API key revoked successfully")
|
||||||
});
|
|
||||||
toast.success("API access credential revoked");
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to revoke API key")
|
||||||
apiError.response?.data?.message || "Failed to revoke access",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">API Keys</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
API Gateway
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Management of system access credentials and authentication tokens.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between bg-gray-50/30">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>All API Keys</CardTitle>
|
||||||
Credential Registry
|
|
||||||
</CardTitle>
|
|
||||||
<Zap className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading API keys...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Key Identifier
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Name</TableHead>
|
||||||
Operator
|
<TableHead>User</TableHead>
|
||||||
</th>
|
<TableHead>Last Used</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Status</TableHead>
|
||||||
Last Activity
|
<TableHead>Actions</TableHead>
|
||||||
</th>
|
</TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
</TableHeader>
|
||||||
Access Status
|
<TableBody>
|
||||||
</th>
|
{apiKeys?.map((key: ApiKey) => (
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<TableRow key={key.id}>
|
||||||
Actions
|
<TableCell className="font-medium">{key.name}</TableCell>
|
||||||
</th>
|
<TableCell>{key.userId || 'N/A'}</TableCell>
|
||||||
</tr>
|
<TableCell>
|
||||||
</thead>
|
{key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'}
|
||||||
<tbody className="divide-y text-gray-600">
|
</TableCell>
|
||||||
{isLoading ? (
|
<TableCell>
|
||||||
<tr>
|
<Badge variant={key.isActive ? 'default' : 'destructive'}>
|
||||||
<td
|
{key.isActive ? 'Active' : 'Revoked'}
|
||||||
colSpan={5}
|
</Badge>
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
</TableCell>
|
||||||
>
|
<TableCell>
|
||||||
Retrieving secure credentials...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : apiKeys && apiKeys.length > 0 ? (
|
|
||||||
apiKeys.map((key: ApiKey) => (
|
|
||||||
<tr
|
|
||||||
key={key.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Key className="w-3 h-3 text-gray-300" />
|
|
||||||
<span className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{key.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium">
|
|
||||||
<User className="w-3 h-3 text-gray-300" />
|
|
||||||
{key.userId || "N/A"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium">
|
|
||||||
<Calendar className="w-3 h-3 text-gray-300" />
|
|
||||||
{key.lastUsed
|
|
||||||
? format(new Date(key.lastUsed), "MMM dd, yyyy")
|
|
||||||
: "Inactive"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border rounded-none",
|
|
||||||
key.isActive
|
|
||||||
? "bg-emerald-50 text-emerald-600 border-emerald-100"
|
|
||||||
: "bg-rose-50 text-rose-600 border-rose-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{key.isActive ? "Authorized" : "Deactivated"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
{key.isActive && (
|
{key.isActive && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-8 rounded-none border border-transparent hover:border-rose-100 hover:bg-rose-50 hover:text-rose-600 transition-all font-bold uppercase tracking-widest text-[9px]"
|
|
||||||
onClick={() => revokeMutation.mutate(key.id)}
|
onClick={() => revokeMutation.mutate(key.id)}
|
||||||
>
|
>
|
||||||
<Ban className="w-3 h-3 mr-2" /> Revoke Access
|
<Ban className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableBody>
|
||||||
<tr>
|
</Table>
|
||||||
<td
|
{apiKeys?.length === 0 && (
|
||||||
colSpan={5}
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
No API keys found
|
||||||
>
|
</div>
|
||||||
No API access credentials defined.
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
)}
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,177 +1,105 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Search, Ban, ChevronLeft, ChevronRight, Filter } from "lucide-react";
|
import { Button } from "@/components/ui/button"
|
||||||
import { securityService, type FailedLogin } from "@/services";
|
import {
|
||||||
import { format } from "date-fns";
|
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"
|
||||||
|
|
||||||
export default function FailedLoginsPage() {
|
export default function FailedLoginsPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page] = useState(1)
|
||||||
const [limit] = useState(15);
|
const [limit] = useState(50)
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const { data: failedLogins, isLoading } = useQuery({
|
const { data: failedLogins, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "security", "failed-logins", page, limit, search],
|
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: Record<string, string | number> = { page, limit };
|
const params: Record<string, string | number> = { page, limit }
|
||||||
if (search) params.email = search;
|
if (search) params.email = search
|
||||||
return await securityService.getFailedLogins(params);
|
return await securityService.getFailedLogins(params)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Failed Login Attempts</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Access Violations
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Audit trail for authentication failures and potential threats.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
Violation Ledger
|
<CardTitle>Failed Logins</CardTitle>
|
||||||
</CardTitle>
|
<div className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
<Input
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs focus-visible:ring-gray-900"
|
placeholder="Search by email..."
|
||||||
placeholder="Search by identifier..."
|
className="pl-10 w-64"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading failed logins...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Identity
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Email</TableHead>
|
||||||
Network Source
|
<TableHead>IP Address</TableHead>
|
||||||
</th>
|
<TableHead>User Agent</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Reason</TableHead>
|
||||||
Failure Reason
|
<TableHead>Attempted At</TableHead>
|
||||||
</th>
|
<TableHead>Blocked</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Actions</TableHead>
|
||||||
Timestamp
|
</TableRow>
|
||||||
</th>
|
</TableHeader>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<TableBody>
|
||||||
Actions
|
{failedLogins?.data?.map((login: FailedLogin) => (
|
||||||
</th>
|
<TableRow key={login.id}>
|
||||||
</tr>
|
<TableCell className="font-medium">{login.email}</TableCell>
|
||||||
</thead>
|
<TableCell className="font-mono text-sm">{login.ipAddress}</TableCell>
|
||||||
<tbody className="divide-y text-gray-600">
|
<TableCell className="max-w-xs truncate">{login.ipAddress}</TableCell>
|
||||||
{isLoading ? (
|
<TableCell>{login.reason || 'N/A'}</TableCell>
|
||||||
<tr>
|
<TableCell>
|
||||||
<td
|
{format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
|
||||||
colSpan={5}
|
</TableCell>
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
<TableCell>
|
||||||
>
|
<Badge variant="secondary">
|
||||||
Synchronizing security data...
|
N/A
|
||||||
</td>
|
</Badge>
|
||||||
</tr>
|
</TableCell>
|
||||||
) : failedLogins?.data && failedLogins.data.length > 0 ? (
|
<TableCell>
|
||||||
failedLogins.data.map((login: FailedLogin) => (
|
<Button variant="ghost" size="icon">
|
||||||
<tr
|
<Ban className="w-4 h-4" />
|
||||||
key={login.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{login.email}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-mono font-medium">
|
|
||||||
{login.ipAddress}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400 uppercase tracking-tighter truncate max-w-[120px]">
|
|
||||||
Gateway Transit
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest bg-rose-50 text-rose-600 border border-rose-100">
|
|
||||||
{login.reason || "Auth Failure"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-xs font-medium">
|
|
||||||
{format(new Date(login.timestamp), "MMM dd, HH:mm:ss")}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<Ban className="w-3.5 h-3.5 text-gray-400 hover:text-rose-600" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableBody>
|
||||||
<tr>
|
</Table>
|
||||||
<td
|
{failedLogins?.data?.length === 0 && (
|
||||||
colSpan={5}
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
No failed login attempts found
|
||||||
>
|
</div>
|
||||||
No access violations recorded.
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
)}
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{failedLogins && (
|
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Total Violations: {failedLogins.total || 0}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-none"
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
disabled={
|
|
||||||
!failedLogins?.data || failedLogins.data.length < limit
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,86 @@
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import {
|
import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
|
||||||
Shield,
|
import { useNavigate } from "react-router-dom"
|
||||||
AlertTriangle,
|
|
||||||
Key,
|
|
||||||
Gauge,
|
|
||||||
Users,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function SecurityPage() {
|
export default function SecurityPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-3xl font-bold">Security</h2>
|
<h2 className="text-3xl font-bold">Security</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/failed-logins')}>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Failed Logins",
|
<CardTitle className="flex items-center gap-2">
|
||||||
description: "View and manage failed login attempts",
|
<AlertTriangle className="w-5 h-5" />
|
||||||
icon: AlertTriangle,
|
Failed Logins
|
||||||
path: "/admin/security/failed-logins",
|
</CardTitle>
|
||||||
color: "text-rose-600",
|
</CardHeader>
|
||||||
},
|
<CardContent>
|
||||||
{
|
<p className="text-sm text-muted-foreground">
|
||||||
label: "Suspicious Activity",
|
View and manage failed login attempts
|
||||||
description: "Monitor suspicious IPs and emails",
|
</p>
|
||||||
icon: Shield,
|
</CardContent>
|
||||||
path: "/admin/security/suspicious",
|
</Card>
|
||||||
color: "text-amber-600",
|
|
||||||
},
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/suspicious')}>
|
||||||
{
|
<CardHeader>
|
||||||
label: "API Keys",
|
<CardTitle className="flex items-center gap-2">
|
||||||
description: "Manage API keys and tokens",
|
<Shield className="w-5 h-5" />
|
||||||
icon: Key,
|
Suspicious Activity
|
||||||
path: "/admin/security/api-keys",
|
</CardTitle>
|
||||||
color: "text-blue-600",
|
</CardHeader>
|
||||||
},
|
<CardContent>
|
||||||
{
|
<p className="text-sm text-muted-foreground">
|
||||||
label: "Rate Limits",
|
Monitor suspicious IPs and emails
|
||||||
description: "View rate limit violations",
|
</p>
|
||||||
icon: Gauge,
|
</CardContent>
|
||||||
path: "/admin/security/rate-limits",
|
</Card>
|
||||||
color: "text-purple-600",
|
|
||||||
},
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/api-keys')}>
|
||||||
{
|
<CardHeader>
|
||||||
label: "Active Sessions",
|
<CardTitle className="flex items-center gap-2">
|
||||||
description: "Manage active user sessions",
|
<Key className="w-5 h-5" />
|
||||||
icon: Users,
|
API Keys
|
||||||
path: "/admin/security/sessions",
|
</CardTitle>
|
||||||
color: "text-emerald-600",
|
</CardHeader>
|
||||||
},
|
<CardContent>
|
||||||
].map((item) => (
|
<p className="text-sm text-muted-foreground">
|
||||||
<Card
|
Manage API keys and tokens
|
||||||
key={item.label}
|
</p>
|
||||||
className="group cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden"
|
</CardContent>
|
||||||
onClick={() => navigate(item.path)}
|
</Card>
|
||||||
>
|
|
||||||
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/rate-limits')}>
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
<CardTitle className="flex items-center gap-2">
|
||||||
{item.label}
|
<Gauge className="w-5 h-5" />
|
||||||
</span>
|
Rate Limits
|
||||||
<item.icon
|
</CardTitle>
|
||||||
className={cn("w-4 h-4 transition-colors", item.color)}
|
</CardHeader>
|
||||||
/>
|
<CardContent>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
View rate limit violations
|
||||||
<CardContent className="pt-4 flex items-end justify-between">
|
</p>
|
||||||
<div>
|
</CardContent>
|
||||||
<p className="text-sm font-semibold text-slate-900 tracking-tight mb-1 group-hover:text-primary transition-colors">
|
</Card>
|
||||||
{item.label}
|
|
||||||
</p>
|
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/sessions')}>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-[200px]">
|
<CardHeader>
|
||||||
{item.description}
|
<CardTitle className="flex items-center gap-2">
|
||||||
</p>
|
<Users className="w-5 h-5" />
|
||||||
</div>
|
Active Sessions
|
||||||
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:translate-x-1 transition-transform" />
|
</CardTitle>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
<CardContent>
|
||||||
))}
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage active user sessions
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,136 +1,65 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Gauge, Clock, Activity, AlertTriangle } from "lucide-react";
|
import {
|
||||||
import { securityService } from "@/services";
|
Table,
|
||||||
import type { RateLimitViolation } from "@/types/security.types";
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { securityService } from "@/services"
|
||||||
|
import type { RateLimitViolation } from "@/types/security.types"
|
||||||
|
|
||||||
export default function RateLimitsPage() {
|
export default function RateLimitsPage() {
|
||||||
const { data: violations, isLoading } = useQuery({
|
const { data: violations, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "security", "rate-limits"],
|
queryKey: ['admin', 'security', 'rate-limits'],
|
||||||
queryFn: () => securityService.getRateLimitViolations(7),
|
queryFn: () => securityService.getRateLimitViolations(7),
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Rate Limit Violations</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Traffic Control
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Audit of rate limit violations and anomalous request frequencies.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between bg-gray-50/30">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>Recent Violations (Last 7 Days)</CardTitle>
|
||||||
Violation Registry
|
|
||||||
</CardTitle>
|
|
||||||
<Gauge className="w-4 h-4 text-gray-300" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading violations...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Protocol Identifier
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>User</TableHead>
|
||||||
Network Origin
|
<TableHead>IP Address</TableHead>
|
||||||
</th>
|
<TableHead>Requests</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
|
<TableHead>Period</TableHead>
|
||||||
Velocity
|
</TableRow>
|
||||||
</th>
|
</TableHeader>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
|
<TableBody>
|
||||||
Reference Period
|
{violations?.map((violation: RateLimitViolation) => (
|
||||||
</th>
|
<TableRow key={violation.id}>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<TableCell>{violation.userId || 'N/A'}</TableCell>
|
||||||
Severity
|
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>
|
||||||
</th>
|
<TableCell>{violation.requests}</TableCell>
|
||||||
</tr>
|
<TableCell>{violation.period}</TableCell>
|
||||||
</thead>
|
</TableRow>
|
||||||
<tbody className="divide-y text-gray-600">
|
))}
|
||||||
{isLoading ? (
|
</TableBody>
|
||||||
<tr>
|
</Table>
|
||||||
<td
|
{violations?.length === 0 && (
|
||||||
colSpan={5}
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
No rate limit violations found
|
||||||
>
|
</div>
|
||||||
Analyzing traffic patterns...
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
)}
|
||||||
) : violations && violations.length > 0 ? (
|
|
||||||
violations.map((violation: RateLimitViolation) => (
|
|
||||||
<tr
|
|
||||||
key={violation.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-3 h-3 text-gray-300" />
|
|
||||||
<span className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{violation.userId || "PUBLIC_TRANSIT"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="text-xs font-mono font-medium">
|
|
||||||
{violation.ipAddress}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1.5 text-xs font-bold text-gray-900">
|
|
||||||
<Activity className="w-3 h-3 text-rose-500" />
|
|
||||||
{violation.requests}{" "}
|
|
||||||
<span className="text-[10px] text-gray-400 font-medium">
|
|
||||||
REQ
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-center">
|
|
||||||
<span className="text-xs font-medium bg-gray-50 border px-2 py-0.5">
|
|
||||||
{violation.period}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-amber-100 bg-amber-50 text-amber-600 rounded-none">
|
|
||||||
Warning
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Traffic volume within nominal limits.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="p-6 bg-slate-50 border border-slate-100 rounded-none flex items-start gap-4">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-slate-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-900">
|
|
||||||
Adaptive Throttling Active
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 leading-relaxed font-medium">
|
|
||||||
System is currently monitoring high-velocity traffic. Automatic
|
|
||||||
blocking protocols will engage if violation frequency exceeds 5% of
|
|
||||||
total ingress.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,75 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { LogOut, Monitor, MapPin, Clock } from "lucide-react";
|
import {
|
||||||
import { securityService, type ActiveSession } from "@/services";
|
Table,
|
||||||
import { format } from "date-fns";
|
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"
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
const { data: sessions, isLoading } = useQuery({
|
const { data: sessions, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "security", "sessions"],
|
queryKey: ['admin', 'security', 'sessions'],
|
||||||
queryFn: () => securityService.getActiveSessions(),
|
queryFn: () => securityService.getActiveSessions(),
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Active Sessions</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Active Sessions
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Live oversight of authenticated system access.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>All Active Sessions</CardTitle>
|
||||||
Access Registry
|
|
||||||
</CardTitle>
|
|
||||||
<div className="px-3 py-1 bg-emerald-50 border border-emerald-100 text-[10px] font-bold text-emerald-600 uppercase tracking-widest">
|
|
||||||
{sessions?.length || 0} Authenticated Sessions
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading sessions...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
Operator Identity
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>User</TableHead>
|
||||||
Endpoint
|
<TableHead>IP Address</TableHead>
|
||||||
</th>
|
<TableHead>User Agent</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Last Activity</TableHead>
|
||||||
Environment
|
<TableHead>Actions</TableHead>
|
||||||
</th>
|
</TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
</TableHeader>
|
||||||
Activity Status
|
<TableBody>
|
||||||
</th>
|
{sessions?.map((session: ActiveSession) => (
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<TableRow key={session.id}>
|
||||||
Control
|
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
|
||||||
</th>
|
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>
|
||||||
</tr>
|
<TableCell className="max-w-xs truncate">{session.userAgent}</TableCell>
|
||||||
</thead>
|
<TableCell>
|
||||||
<tbody className="divide-y">
|
{format(new Date(session.lastActivity), 'MMM dd, yyyy HH:mm')}
|
||||||
{isLoading ? (
|
</TableCell>
|
||||||
<tr>
|
<TableCell>
|
||||||
<td
|
<Button variant="ghost" size="icon">
|
||||||
colSpan={5}
|
<LogOut className="w-4 h-4" />
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Interrogating active sessions...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : sessions && sessions.length > 0 ? (
|
|
||||||
sessions.map((session: ActiveSession) => (
|
|
||||||
<tr
|
|
||||||
key={session.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{session.userId || "N/A"}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400 uppercase font-medium">
|
|
||||||
Internal Reference
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-mono font-medium text-gray-600">
|
|
||||||
<MapPin className="w-3 h-3 text-gray-300" />
|
|
||||||
{session.ipAddress}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-gray-500">
|
|
||||||
<Monitor className="w-3 h-3 text-gray-300" />
|
|
||||||
<span className="truncate max-w-[150px]">
|
|
||||||
{session.userAgent}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-gray-900">
|
|
||||||
<Clock className="w-3 h-3 text-emerald-500" />
|
|
||||||
{format(new Date(session.lastActivity), "HH:mm:ss")}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-none border border-transparent hover:border-rose-100 hover:bg-rose-50 hover:text-rose-600 transition-all font-bold uppercase tracking-widest text-[9px]"
|
|
||||||
>
|
|
||||||
<LogOut className="w-3 h-3 mr-2" /> Revoke
|
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableBody>
|
||||||
<tr>
|
</Table>
|
||||||
<td
|
{sessions?.length === 0 && (
|
||||||
colSpan={5}
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
No active sessions found
|
||||||
>
|
</div>
|
||||||
No active authenticated sessions.
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
)}
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,88 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Shield, Ban, Mail, Globe, AlertTriangle } from "lucide-react";
|
import { Shield, Ban } from "lucide-react"
|
||||||
import { securityService } from "@/services";
|
import { securityService } from "@/services"
|
||||||
import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types";
|
import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types"
|
||||||
|
|
||||||
export default function SuspiciousActivityPage() {
|
export default function SuspiciousActivityPage() {
|
||||||
const { data: suspicious, isLoading } = useQuery({
|
const { data: suspicious, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "security", "suspicious"],
|
queryKey: ['admin', 'security', 'suspicious'],
|
||||||
queryFn: () => securityService.getSuspiciousActivity(),
|
queryFn: () => securityService.getSuspiciousActivity(),
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<h2 className="text-3xl font-bold">Suspicious Activity</h2>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
Anomalous Activity
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
High-risk identifiers flagged for potential system abuse.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card className="border shadow-none rounded-none overflow-hidden">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Globe className="w-4 h-4 text-gray-400" />
|
<Shield className="w-5 h-5" />
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
Suspicious IP Addresses
|
||||||
Suspicious Network Ingress
|
</CardTitle>
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-rose-50 text-rose-600 border border-rose-100">
|
|
||||||
Shield Active
|
|
||||||
</span>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
<div className="text-center py-8">Loading...</div>
|
||||||
Interrogating global threats...
|
|
||||||
</div>
|
|
||||||
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
|
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
|
||||||
<div className="divide-y">
|
<div className="space-y-2">
|
||||||
{suspicious?.suspiciousIPs?.map(
|
{suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => (
|
||||||
(ip: SuspiciousIP, index: number) => (
|
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
<div
|
<div>
|
||||||
key={index}
|
<p className="font-mono font-medium">{ip.ipAddress}</p>
|
||||||
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
|
<p className="text-sm text-muted-foreground">{ip.attempts} attempts</p>
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold font-mono text-gray-900 tracking-tighter">
|
|
||||||
{ip.ipAddress}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
|
|
||||||
{ip.attempts} Flagged Interactions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-none border border-transparent hover:border-gray-900 hover:bg-gray-900 hover:text-white transition-all font-bold uppercase tracking-widest text-[9px]"
|
|
||||||
>
|
|
||||||
<Ban className="w-3 h-3 mr-2" /> Block IP
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
<Button variant="outline" size="sm">
|
||||||
)}
|
<Ban className="w-4 h-4 mr-2" />
|
||||||
|
Block
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No high-risk network sources.
|
No suspicious IPs found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none overflow-hidden">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Mail className="w-4 h-4 text-gray-400" />
|
<Shield className="w-5 h-5" />
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
Suspicious Emails
|
||||||
Flagged Credentials
|
</CardTitle>
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100">
|
|
||||||
Monitoring
|
|
||||||
</span>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
<div className="text-center py-8">Loading...</div>
|
||||||
Screening identity registry...
|
|
||||||
</div>
|
|
||||||
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
|
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
|
||||||
<div className="divide-y">
|
<div className="space-y-2">
|
||||||
{suspicious?.suspiciousEmails?.map(
|
{suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => (
|
||||||
(email: SuspiciousEmail, index: number) => (
|
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
<div
|
<div>
|
||||||
key={index}
|
<p className="font-medium">{email.email}</p>
|
||||||
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
|
<p className="text-sm text-muted-foreground">{email.attempts} attempts</p>
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{email.email}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
|
|
||||||
{email.attempts} Security Triggers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-none border border-transparent hover:border-gray-900 hover:bg-gray-900 hover:text-white transition-all font-bold uppercase tracking-widest text-[9px]"
|
|
||||||
>
|
|
||||||
<Ban className="w-3 h-3 mr-2" /> Block Domain
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
<Button variant="outline" size="sm">
|
||||||
)}
|
<Ban className="w-4 h-4 mr-2" />
|
||||||
|
Block
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No suspicious identity triggers.
|
No suspicious emails found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-amber-50 border border-amber-100 rounded-none flex items-start gap-4">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-amber-900">
|
|
||||||
Protocol Awareness
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-amber-700 leading-relaxed font-medium">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,211 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { settingsService, type Setting } from "@/services";
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { toast } from "sonner";
|
import {
|
||||||
import type { ApiError } from "@/types/error.types";
|
Dialog,
|
||||||
import { cn } from "@/lib/utils";
|
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"
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL");
|
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL")
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [newSetting, setNewSetting] = useState({
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
description: "",
|
||||||
|
isPublic: false,
|
||||||
|
})
|
||||||
|
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "settings", selectedCategory],
|
queryKey: ['admin', 'settings', selectedCategory],
|
||||||
queryFn: () => settingsService.getSettings(selectedCategory),
|
queryFn: () => settingsService.getSettings(selectedCategory),
|
||||||
});
|
})
|
||||||
|
|
||||||
const updateSettingMutation = useMutation({
|
const updateSettingMutation = useMutation({
|
||||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
settingsService.updateSetting(key, { value }),
|
settingsService.updateSetting(key, { value }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
toast.success("Setting updated successfully");
|
toast.success("Setting updated successfully")
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
toast.error(
|
toast.error(apiError.response?.data?.message || "Failed to update setting")
|
||||||
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) => {
|
const handleSave = (key: string, value: string) => {
|
||||||
updateSettingMutation.mutate({ key, value });
|
updateSettingMutation.mutate({ key, value })
|
||||||
};
|
}
|
||||||
|
|
||||||
const categories = [
|
const handleCreate = () => {
|
||||||
"GENERAL",
|
if (!newSetting.key || !newSetting.value) {
|
||||||
"EMAIL",
|
toast.error("Key and value are required")
|
||||||
"STORAGE",
|
return
|
||||||
"SECURITY",
|
}
|
||||||
"API",
|
createSettingMutation.mutate({
|
||||||
"FEATURES",
|
key: newSetting.key,
|
||||||
];
|
value: newSetting.value,
|
||||||
|
category: selectedCategory,
|
||||||
|
description: newSetting.description || undefined,
|
||||||
|
isPublic: newSetting.isPublic,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<h2 className="text-3xl font-bold">System Settings</h2>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
System Settings
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
</h1>
|
Create Setting
|
||||||
<p className="text-gray-500 mt-1">
|
</Button>
|
||||||
Configure global application parameters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* View only access: Create Setting button removed */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||||
value={selectedCategory}
|
<TabsList>
|
||||||
onValueChange={setSelectedCategory}
|
<TabsTrigger value="GENERAL">General</TabsTrigger>
|
||||||
className="space-y-6"
|
<TabsTrigger value="EMAIL">Email</TabsTrigger>
|
||||||
>
|
<TabsTrigger value="STORAGE">Storage</TabsTrigger>
|
||||||
<TabsList className="bg-gray-100/50 p-1 rounded-none border border-gray-200">
|
<TabsTrigger value="SECURITY">Security</TabsTrigger>
|
||||||
{categories.map((cat) => (
|
<TabsTrigger value="API">API</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger value="FEATURES">Features</TabsTrigger>
|
||||||
key={cat}
|
|
||||||
value={cat}
|
|
||||||
className="rounded-none data-[state=active]:bg-white data-[state=active]:shadow-sm px-6 text-[10px] font-bold uppercase tracking-widest transition-all"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value={selectedCategory} className="outline-none">
|
<TabsContent value={selectedCategory} className="space-y-4">
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 bg-gray-50/30">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle>{selectedCategory} Settings</CardTitle>
|
||||||
{selectedCategory} Configuration
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0 divide-y">
|
<CardContent className="space-y-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
|
<div className="text-center py-8">Loading settings...</div>
|
||||||
Fetching system variables...
|
|
||||||
</div>
|
|
||||||
) : settings && settings.length > 0 ? (
|
) : settings && settings.length > 0 ? (
|
||||||
settings.map((setting: Setting) => (
|
settings.map((setting: Setting) => (
|
||||||
<div
|
<div key={setting.key} className="space-y-2">
|
||||||
key={setting.key}
|
<Label htmlFor={setting.key}>{setting.key}</Label>
|
||||||
className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-6 hover:bg-gray-50/50 transition-colors"
|
<div className="flex gap-2">
|
||||||
>
|
|
||||||
<div className="max-w-md">
|
|
||||||
<Label
|
|
||||||
className="text-sm font-bold text-gray-900 uppercase tracking-tighter"
|
|
||||||
htmlFor={setting.key}
|
|
||||||
>
|
|
||||||
{setting.key.replace(/\./g, " / ")}
|
|
||||||
</Label>
|
|
||||||
{setting.description && (
|
|
||||||
<p className="text-xs text-gray-400 mt-1 leading-relaxed">
|
|
||||||
{setting.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 md:max-w-sm">
|
|
||||||
<Input
|
<Input
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
defaultValue={setting.value}
|
defaultValue={setting.value}
|
||||||
className={cn(
|
|
||||||
"h-10 rounded-none border-gray-200 text-sm font-medium focus-visible:ring-gray-900",
|
|
||||||
updateSettingMutation.isPending &&
|
|
||||||
"opacity-50 pointer-events-none",
|
|
||||||
)}
|
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (e.target.value !== setting.value) {
|
if (e.target.value !== setting.value) {
|
||||||
handleSave(setting.key, e.target.value);
|
handleSave(setting.key, e.target.value)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{setting.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{setting.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="p-20 text-center text-gray-400 italic">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No variables defined for this category.
|
No settings found for this category
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create Setting Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Setting</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new system setting in the {selectedCategory} category
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setting-key">Key *</Label>
|
||||||
|
<Input
|
||||||
|
id="setting-key"
|
||||||
|
placeholder="setting.key"
|
||||||
|
value={newSetting.key}
|
||||||
|
onChange={(e) => setNewSetting({ ...newSetting, key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setting-value">Value *</Label>
|
||||||
|
<Input
|
||||||
|
id="setting-value"
|
||||||
|
placeholder="Setting value"
|
||||||
|
value={newSetting.value}
|
||||||
|
onChange={(e) => setNewSetting({ ...newSetting, value: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setting-description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="setting-description"
|
||||||
|
placeholder="Setting description"
|
||||||
|
value={newSetting.description}
|
||||||
|
onChange={(e) => setNewSetting({ ...newSetting, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="setting-public"
|
||||||
|
checked={newSetting.isPublic}
|
||||||
|
onCheckedChange={(checked) => setNewSetting({ ...newSetting, isPublic: checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="setting-public" className="text-sm">
|
||||||
|
Public (accessible via API)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setNewSetting({ key: "", value: "", description: "", isPublic: false })
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={createSettingMutation.isPending}>
|
||||||
|
{createSettingMutation.isPending ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold">Subscription Plans</h2>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Manage plan pricing, feature flags, limits, and activation status.
|
|
||||||
Payment history is under{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
|
||||||
onClick={() => navigate("/admin/transactions/subscriptions")}
|
|
||||||
>
|
|
||||||
Subscriptions → Transactions
|
|
||||||
</button>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All Plans</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{plansLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
Loading plans...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Plan</TableHead>
|
|
||||||
<TableHead>Monthly (ETB)</TableHead>
|
|
||||||
<TableHead>Yearly (ETB)</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{plans?.map((plan) => (
|
|
||||||
<TableRow key={plan.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{plan.displayName}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{plan.name}</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{plan.isFree ? "Free" : plan.monthlyPrice.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{plan.isFree ? "Free" : plan.yearlyPrice.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge className={plan.isActive ? "bg-green-500" : "bg-gray-500"}>
|
|
||||||
{plan.isActive ? "Active" : "Inactive"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/admin/subscriptions/plans/${plan.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
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<PlanFeatures>({ 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 (
|
|
||||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
Loading plan...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return <div className="text-center py-16">Plan not found.</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/subscriptions')}>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold">{plan.displayName}</h2>
|
|
||||||
<p className="text-muted-foreground">{plan.name} plan</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="pricing">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="pricing">Pricing & Status</TabsTrigger>
|
|
||||||
<TabsTrigger value="features">Features & Limits</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="pricing">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Pricing Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 max-w-lg">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="displayName">Display Name</Label>
|
|
||||||
<Input
|
|
||||||
id="displayName"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!plan.isFree && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="monthlyPrice">Monthly Price (ETB)</Label>
|
|
||||||
<Input
|
|
||||||
id="monthlyPrice"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={monthlyPrice}
|
|
||||||
onChange={(e) => setMonthlyPrice(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="yearlyPrice">Yearly Price (ETB)</Label>
|
|
||||||
<Input
|
|
||||||
id="yearlyPrice"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={yearlyPrice}
|
|
||||||
onChange={(e) => setYearlyPrice(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="isActive">Plan Active</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Inactive plans are hidden from new subscriptions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch id="isActive" checked={isActive} onCheckedChange={setIsActive} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => updatePlanMutation.mutate()}
|
|
||||||
disabled={updatePlanMutation.isPending}
|
|
||||||
>
|
|
||||||
{updatePlanMutation.isPending && (
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Save Pricing
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="features">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Feature Flags</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Object.entries(features.features).map(([key, enabled]) => (
|
|
||||||
<div key={key} className="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<Label htmlFor={`feature-${key}`}>{formatFeatureLabel(key)}</Label>
|
|
||||||
<Switch
|
|
||||||
id={`feature-${key}`}
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={() => toggleFeature(key)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Usage Limits</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Object.entries(features.limits).map(([key, limit]) => (
|
|
||||||
<div key={key} className="space-y-1">
|
|
||||||
<Label htmlFor={`limit-${key}`}>{formatFeatureLabel(key)}</Label>
|
|
||||||
<Input
|
|
||||||
id={`limit-${key}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="Unlimited (empty)"
|
|
||||||
value={limit === null ? "" : String(limit)}
|
|
||||||
onChange={(e) => updateLimit(key, e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => updateFeaturesMutation.mutate()}
|
|
||||||
disabled={updateFeaturesMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateFeaturesMutation.isPending && (
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Save Features
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,484 +0,0 @@
|
||||||
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<FaqAudience | "ALL">(
|
|
||||||
"ALL",
|
|
||||||
);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<FaqEntry | null>(null);
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
question: "",
|
|
||||||
answer: "",
|
|
||||||
audience: "ALL" as FaqAudience,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["admin", "faq", search, audienceFilter],
|
|
||||||
queryFn: () =>
|
|
||||||
faqService.list({
|
|
||||||
limit: 100,
|
|
||||||
search: search.trim() || undefined,
|
|
||||||
audience: audienceFilter === "ALL" ? undefined : audienceFilter,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (editing) {
|
|
||||||
return faqService.update(editing.id, {
|
|
||||||
question: form.question,
|
|
||||||
answer: form.answer,
|
|
||||||
audience: form.audience,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return faqService.create({
|
|
||||||
question: form.question,
|
|
||||||
answer: form.answer,
|
|
||||||
audience: form.audience,
|
|
||||||
isPublished: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(
|
|
||||||
editing ? "FAQ 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 (
|
|
||||||
<div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
FAQ & <span className="text-primary NOT-italic">Support</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
|
||||||
Curate and manage the central intelligence repository for both
|
|
||||||
standard users and internal system administrators.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-8 border-b border-gray-100">
|
|
||||||
<NavLink
|
|
||||||
to="/admin/support/faq"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
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
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/admin/issues"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
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
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={canEdit ? tab : "browse"}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (canEdit) setTab(v as "browse" | "manage");
|
|
||||||
}}
|
|
||||||
className="space-y-8"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 backdrop-blur-xl p-2 rounded-[6px] ">
|
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
|
||||||
<div className="relative group flex-1 sm:min-w-[280px]">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
|
||||||
<Input
|
|
||||||
className="pl-11 h-10 bg-white border-slate-200/60 rounded-[6px] text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
|
||||||
placeholder="Search articles..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={audienceFilter}
|
|
||||||
onValueChange={(v) => setAudienceFilter(v as FaqAudience | "ALL")}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px] h-10 rounded-[6px] bg-white border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
|
|
||||||
<SelectValue placeholder="Audience" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-xl border-slate-100 shadow-xl">
|
|
||||||
<SelectItem
|
|
||||||
value="ALL"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
All Audiences
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="END_USER"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
End Users
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="SYSTEM_USER"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
System Staff
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="browse" className="mt-0 space-y-6">
|
|
||||||
{error && (
|
|
||||||
<div className="p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
|
|
||||||
<HelpCircle className="w-6 h-6 flex-shrink-0" />
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
Library unreachable. Verify{" "}
|
|
||||||
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
|
||||||
GET /admin/faq
|
|
||||||
</code>{" "}
|
|
||||||
endpoint integrity.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-[200px] bg-slate-100/50 animate-pulse rounded-3xl"
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : browseItems.length ? (
|
|
||||||
browseItems.map((faq) => (
|
|
||||||
<Card
|
|
||||||
key={faq.id}
|
|
||||||
className="border-none shadow-[0_4px_20px_rgb(0,0,0,0.03)] bg-white rounded-3xl overflow-hidden hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all group"
|
|
||||||
>
|
|
||||||
<CardHeader className="p-8 pb-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="p-3 bg-slate-50 rounded-2xl group-hover:bg-primary/5 transition-colors">
|
|
||||||
<HelpCircle className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 py-0.5 border-none",
|
|
||||||
faq.audience === "ALL"
|
|
||||||
? "bg-slate-100 text-slate-500"
|
|
||||||
: faq.audience === "END_USER"
|
|
||||||
? "bg-emerald-500 text-white"
|
|
||||||
: "bg-primary text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{faq.audience === "ALL"
|
|
||||||
? "Global"
|
|
||||||
: faq.audience === "END_USER"
|
|
||||||
? "Customer"
|
|
||||||
: "Internal"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-xl font-black text-slate-900 tracking-tight mt-4 leading-tight">
|
|
||||||
{faq.question}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-8 pb-8 pt-0">
|
|
||||||
<p className="text-slate-500 text-sm font-medium leading-relaxed line-clamp-3">
|
|
||||||
{faq.answer}
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex items-center text-[10px] font-black uppercase tracking-wider text-primary opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
Read Documentation{" "}
|
|
||||||
<ChevronRight className="w-3 h-3 ml-1" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="col-span-full py-24 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
|
||||||
<Library className="w-16 h-16" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
|
||||||
FAQ Empty
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium italic mt-1">
|
|
||||||
No matches found in the current FAQ repository.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<TabsContent value="manage" className="mt-0 space-y-6">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
className="h-12 px-8 rounded-2xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm({ question: "", answer: "", audience: "ALL" });
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Publish Entry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-50/50 border-b border-slate-100">
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[60%]">
|
|
||||||
Intelligence Subject
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Target Audience
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Operations
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{data?.data?.map((faq) => (
|
|
||||||
<tr
|
|
||||||
key={faq.id}
|
|
||||||
className="group hover:bg-slate-50/50 transition-colors uppercase"
|
|
||||||
>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<span className="text-sm font-black text-slate-900 line-clamp-1 tracking-tight">
|
|
||||||
{faq.question}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{faq.audience === "ALL" ? (
|
|
||||||
<Globe className="w-3.5 h-3.5 text-slate-400" />
|
|
||||||
) : faq.audience === "END_USER" ? (
|
|
||||||
<Users className="w-3.5 h-3.5 text-emerald-500" />
|
|
||||||
) : (
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-primary" />
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] font-bold text-slate-600">
|
|
||||||
{faq.audience}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6 text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 p-0 rounded-xl hover:bg-white hover:shadow-md transition-all"
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(faq);
|
|
||||||
setForm({
|
|
||||||
question: faq.question,
|
|
||||||
answer: faq.answer,
|
|
||||||
audience: faq.audience,
|
|
||||||
});
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4 text-slate-400" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* FAQ Creation/Edit Modal */}
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="rounded-3xl max-w-lg p-0 border-none shadow-2xl overflow-hidden">
|
|
||||||
<div className="p-8 bg-slate-900 text-white">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="p-2 bg-white/10 rounded-xl">
|
|
||||||
<HelpCircle className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
|
||||||
Intelligence Editor
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
|
||||||
{editing ? "Refine" : "Commit"}{" "}
|
|
||||||
<span className="text-primary NOT-italic">Intelligence</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
|
||||||
Authoritative content for the platform knowledge base. Ensure
|
|
||||||
semantic clarity and audience alignment.
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8 space-y-6 max-h-[60vh] overflow-y-auto">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
|
||||||
Visibility Tier
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={form.audience}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, audience: v as FaqAudience }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-xl">
|
|
||||||
<SelectItem
|
|
||||||
value="ALL"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
Global Visibility
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="END_USER"
|
|
||||||
className="text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
Platform Customers Only
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="SYSTEM_USER"
|
|
||||||
className="text-[10px] font-black uppercase text-primary"
|
|
||||||
>
|
|
||||||
Internal Panel Staff Only
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="faq-q"
|
|
||||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
|
||||||
>
|
|
||||||
Article Title / Question
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="faq-q"
|
|
||||||
value={form.question}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, question: e.target.value }))
|
|
||||||
}
|
|
||||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
|
|
||||||
placeholder="e.g. How to manage multiple ledgers?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="faq-a"
|
|
||||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
|
||||||
>
|
|
||||||
Authoritative Answer
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
id="faq-a"
|
|
||||||
value={form.answer}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, answer: e.target.value }))
|
|
||||||
}
|
|
||||||
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
|
|
||||||
placeholder="Provide precise, actionable steps..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
Discard
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-12 px-10 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-primary/20 transition-all active:scale-95"
|
|
||||||
disabled={
|
|
||||||
saveMutation.isPending ||
|
|
||||||
!form.question.trim() ||
|
|
||||||
!form.answer.trim()
|
|
||||||
}
|
|
||||||
onClick={() => saveMutation.mutate()}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? "Syncing..." : "Publish Intelligence"}
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { Navigate } from "react-router-dom"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Search, UserPlus } from "lucide-react"
|
|
||||||
import { systemMemberService } from "@/services"
|
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role"
|
|
||||||
import { AdminRole } from "@/lib/admin-roles"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export default function SystemMembersPage() {
|
|
||||||
const { canAccessSystemMembers, canEdit } = useAdminRole()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
email: "",
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
password: "",
|
|
||||||
role: AdminRole.CUSTOMER_SUPPORT,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["admin", "system-members", page, search],
|
|
||||||
queryFn: () =>
|
|
||||||
systemMemberService.list({
|
|
||||||
page,
|
|
||||||
limit: 10,
|
|
||||||
search: search.trim() || undefined,
|
|
||||||
}),
|
|
||||||
enabled: canAccessSystemMembers,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: () => systemMemberService.create(form),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("System user created")
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "system-members"] })
|
|
||||||
setOpen(false)
|
|
||||||
setForm({
|
|
||||||
email: "",
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
password: "",
|
|
||||||
role: AdminRole.CUSTOMER_SUPPORT,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Failed to create user"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!canAccessSystemMembers) {
|
|
||||||
return <Navigate to="/admin/dashboard" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
||||||
System users
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1 max-w-xl">
|
|
||||||
Internal staff who can access this panel. Actors: System Admin (full
|
|
||||||
access), Admin (view & edit), Customer Support (view-only on most
|
|
||||||
areas; cannot manage this list).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
className="rounded-none gap-2"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<UserPlus className="h-4 w-4" />
|
|
||||||
Add system user
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
|
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Directory
|
|
||||||
</CardTitle>
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
|
||||||
placeholder="Search name or email…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{error && (
|
|
||||||
<p className="p-6 text-sm text-amber-700 bg-amber-50 border-b">
|
|
||||||
Could not reach{" "}
|
|
||||||
<code className="text-xs">GET /admin/system-members</code>. Add this
|
|
||||||
route on your API to populate the table.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Email
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Panel role
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="px-6 py-16 text-center text-gray-400 animate-pulse"
|
|
||||||
>
|
|
||||||
Loading…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : data?.data?.length ? (
|
|
||||||
data.data.map((m) => (
|
|
||||||
<tr key={m.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
|
||||||
{m.firstName} {m.lastName}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
|
||||||
{m.email}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<Badge variant="outline" className="rounded-none text-[10px]">
|
|
||||||
{m.role}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right text-xs text-gray-600">
|
|
||||||
{m.isActive ? "Active" : "Disabled"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="px-6 py-16 text-center text-gray-400 italic text-sm"
|
|
||||||
>
|
|
||||||
No system users loaded.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="rounded-none max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add system user</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-3 py-2">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="sm-email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="sm-email"
|
|
||||||
value={form.email}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, email: e.target.value }))
|
|
||||||
}
|
|
||||||
className="rounded-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="sm-fn">First name</Label>
|
|
||||||
<Input
|
|
||||||
id="sm-fn"
|
|
||||||
value={form.firstName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, firstName: e.target.value }))
|
|
||||||
}
|
|
||||||
className="rounded-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="sm-ln">Last name</Label>
|
|
||||||
<Input
|
|
||||||
id="sm-ln"
|
|
||||||
value={form.lastName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, lastName: e.target.value }))
|
|
||||||
}
|
|
||||||
className="rounded-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="sm-pw">Temporary password</Label>
|
|
||||||
<Input
|
|
||||||
id="sm-pw"
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, password: e.target.value }))
|
|
||||||
}
|
|
||||||
className="rounded-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label>Role</Label>
|
|
||||||
<Select
|
|
||||||
value={form.role}
|
|
||||||
onValueChange={(role) =>
|
|
||||||
setForm((f) => ({ ...f, role }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="rounded-none">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={AdminRole.SUPER_ADMIN}>
|
|
||||||
System Admin — full access
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={AdminRole.ADMIN}>
|
|
||||||
Admin — view & edit
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={AdminRole.CUSTOMER_SUPPORT}>
|
|
||||||
Customer Support — view (no member management)
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" className="rounded-none" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="rounded-none"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
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 (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500">
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-primary mb-1">
|
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
|
||||||
<ArrowRightLeft className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-black uppercase tracking-widest opacity-70">
|
|
||||||
Infrastructure
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
|
|
||||||
Subscription{" "}
|
|
||||||
<span className="text-primary NOT-italic">Transactions</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
|
||||||
Monitor real-time platform revenue streams and troubleshoot declined
|
|
||||||
payment attempts across all subscription tiers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8">
|
|
||||||
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
|
||||||
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
|
||||||
<Tabs
|
|
||||||
value={tab}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setTab(v as "completed" | "failed");
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
|
|
||||||
<TabsTrigger
|
|
||||||
value="completed"
|
|
||||||
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
||||||
Completed
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="failed"
|
|
||||||
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-rose-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
|
|
||||||
>
|
|
||||||
<XCircle className="h-3.5 w-3.5" />
|
|
||||||
Failed
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="relative group min-w-[300px]">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
|
||||||
<Input
|
|
||||||
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
|
||||||
placeholder="Query by email, ID or reference..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{error && (
|
|
||||||
<div className="m-8 p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
|
|
||||||
<XCircle className="w-6 h-6 flex-shrink-0" />
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
Failed to synchronize with banking ledger. Verify{" "}
|
|
||||||
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
|
||||||
GET /subscription/admin/transactions
|
|
||||||
</code>{" "}
|
|
||||||
is reachable.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto min-h-[400px]">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-50/50 border-b border-slate-100">
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[28%]">
|
|
||||||
Subscriber Details
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Service Plan
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Transaction Value
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Financial Gateway
|
|
||||||
</th>
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Captured At
|
|
||||||
</th>
|
|
||||||
{tab === "failed" && (
|
|
||||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
Resolution Logic
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
State
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<tr key={i} className="animate-pulse">
|
|
||||||
<td
|
|
||||||
colSpan={tab === "failed" ? 7 : 6}
|
|
||||||
className="px-8 py-6"
|
|
||||||
>
|
|
||||||
<div className="h-4 bg-slate-100 rounded-full w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-slate-50 rounded-full w-1/2"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : data?.data && data.data.length > 0 ? (
|
|
||||||
data.data.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className="group hover:bg-slate-50/50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center text-slate-400">
|
|
||||||
<UserIcon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-black text-slate-900 tracking-tight">
|
|
||||||
{row.userEmail}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
|
||||||
{row.userId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-slate-100 text-slate-700 hover:bg-slate-200 border-none rounded-lg px-2.5 py-0.5 text-[10px] font-black uppercase"
|
|
||||||
>
|
|
||||||
{row.planName}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<span className="text-sm font-black text-slate-900 underline decoration-primary/20 underline-offset-4">
|
|
||||||
{formatMoney(row.amount, row.currency)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-1.5 font-bold text-[11px] text-slate-700">
|
|
||||||
<CreditCard className="w-3.5 h-3.5 text-slate-400" />
|
|
||||||
{row.provider}
|
|
||||||
</div>
|
|
||||||
{row.providerRef && (
|
|
||||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-100/50 px-1.5 py-0.5 rounded border border-slate-200/50 w-fit">
|
|
||||||
{row.providerRef}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-6">
|
|
||||||
<span className="text-xs font-semibold text-slate-500 italic">
|
|
||||||
{new Date(row.createdAt).toLocaleDateString()}
|
|
||||||
<span className="block text-[10px] not-italic opacity-60 mt-1 uppercase font-bold tracking-tighter">
|
|
||||||
{new Date(row.createdAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{tab === "failed" && (
|
|
||||||
<td className="px-8 py-6 max-w-[240px]">
|
|
||||||
<p className="text-xs font-bold text-rose-600 bg-rose-50/50 p-2 rounded-lg border border-rose-100/50">
|
|
||||||
{row.failureReason ?? "Unknown Error Logic"}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="px-8 py-6 text-right">
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-3 py-1 text-[10px] font-black uppercase tracking-widest border-none shadow-sm",
|
|
||||||
row.status === "COMPLETED"
|
|
||||||
? "bg-emerald-500 text-white shadow-emerald-200/50"
|
|
||||||
: row.status === "FAILED"
|
|
||||||
? "bg-rose-500 text-white shadow-rose-200/50"
|
|
||||||
: "bg-amber-500 text-white shadow-amber-200/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={tab === "failed" ? 7 : 6}
|
|
||||||
className="px-6 py-24 text-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-3 opacity-30 grayscale">
|
|
||||||
<Filter className="w-12 h-12" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-black uppercase tracking-widest">
|
|
||||||
No matching logs
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
Adjust your criteria or verify live stream.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
|
||||||
{data && data.totalPages > 1 && (
|
|
||||||
<div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
|
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="text-slate-900">{data.data.length}</span> of{" "}
|
|
||||||
<span className="text-slate-900">{data.total}</span> entries
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" /> Prev
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center px-4 text-xs font-black text-primary bg-white rounded-xl shadow-sm border border-slate-200/50">
|
|
||||||
{data.page} / {data.totalPages}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
|
||||||
disabled={page >= data.totalPages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
Next <ChevronRight className="w-4 h-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,131 +11,81 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select"
|
||||||
import { ArrowLeft, Edit, Key, Loader2, CreditCard } from "lucide-react";
|
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react"
|
||||||
import {
|
import { userService } from "@/services"
|
||||||
userService,
|
import { format } from "date-fns"
|
||||||
subscriptionService,
|
import { useState } from "react"
|
||||||
type BillingInterval,
|
import { toast } from "sonner"
|
||||||
} 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() {
|
export default function UserDetailsPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient();
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||||
const { canEditUsers } = useAdminRole();
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
|
||||||
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [assignPlanId, setAssignPlanId] = useState("");
|
|
||||||
const [assignBillingInterval, setAssignBillingInterval] =
|
|
||||||
useState<BillingInterval>("MONTHLY");
|
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
firstName: "",
|
firstName: '',
|
||||||
lastName: "",
|
lastName: '',
|
||||||
email: "",
|
email: '',
|
||||||
role: "",
|
role: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
const {
|
const { data: user, isLoading, refetch } = useQuery({
|
||||||
data: user,
|
queryKey: ['admin', 'users', id],
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["admin", "users", id],
|
|
||||||
queryFn: () => userService.getUser(id!),
|
queryFn: () => userService.getUser(id!),
|
||||||
enabled: !!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 = () => {
|
const handleEditClick = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
firstName: user.firstName || "",
|
firstName: user.firstName || '',
|
||||||
lastName: user.lastName || "",
|
lastName: user.lastName || '',
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
});
|
})
|
||||||
setIsEditDialogOpen(true);
|
setIsEditDialogOpen(true)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
const handleSaveEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true)
|
||||||
await userService.updateUser(id!, editForm);
|
await userService.updateUser(id!, editForm)
|
||||||
toast.success("User updated successfully");
|
toast.success("User updated successfully")
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false)
|
||||||
refetch();
|
refetch()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update user");
|
toast.error("Failed to update user")
|
||||||
console.error("Update error:", error);
|
console.error('Update error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-center py-8">Loading user details...</div>;
|
return <div className="text-center py-8">Loading user details...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <div className="text-center py-8">User not found</div>;
|
return <div className="text-center py-8">User not found</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/users')}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => navigate("/admin/users")}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-3xl font-bold">User Details</h2>
|
<h2 className="text-3xl font-bold">User Details</h2>
|
||||||
|
|
@ -144,12 +94,8 @@ export default function UserDetailsPage() {
|
||||||
<Tabs defaultValue="info" className="space-y-4">
|
<Tabs defaultValue="info" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="info">Information</TabsTrigger>
|
<TabsTrigger value="info">Information</TabsTrigger>
|
||||||
<TabsTrigger value="subscription">Subscription</TabsTrigger>
|
|
||||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger value="activity" onClick={() => navigate(`/admin/users/${id}/activity`)}>
|
||||||
value="activity"
|
|
||||||
onClick={() => navigate(`/admin/users/${id}/activity`)}
|
|
||||||
>
|
|
||||||
Activity
|
Activity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
@ -160,27 +106,14 @@ export default function UserDetailsPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>User Information</CardTitle>
|
<CardTitle>User Information</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{canEditUsers && (
|
<Button variant="outline" size="sm" onClick={handleEditClick}>
|
||||||
<>
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
<Button
|
Edit
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
<Button variant="outline" size="sm">
|
||||||
onClick={handleEditClick}
|
<Key className="w-4 h-4 mr-2" />
|
||||||
>
|
Reset Password
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
</Button>
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
Reset Password
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!canEditUsers && (
|
|
||||||
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
|
||||||
Immutable View
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -192,9 +125,7 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Name</p>
|
<p className="text-sm text-muted-foreground">Name</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">{user.firstName} {user.lastName}</p>
|
||||||
{user.firstName} {user.lastName}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Role</p>
|
<p className="text-sm text-muted-foreground">Role</p>
|
||||||
|
|
@ -202,22 +133,18 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Status</p>
|
<p className="text-sm text-muted-foreground">Status</p>
|
||||||
<Badge variant={user.isActive ? "default" : "secondary"}>
|
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||||
{user.isActive ? "Active" : "Inactive"}
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Created At</p>
|
<p className="text-sm text-muted-foreground">Created At</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">{format(new Date(user.createdAt), 'PPpp')}</p>
|
||||||
{format(new Date(user.createdAt), "PPpp")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Updated At</p>
|
<p className="text-sm text-muted-foreground">Updated At</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{user.updatedAt
|
{user.updatedAt ? format(new Date(user.updatedAt), 'PPpp') : 'N/A'}
|
||||||
? format(new Date(user.updatedAt), "PPpp")
|
|
||||||
: "N/A"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,78 +152,6 @@ export default function UserDetailsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="subscription" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Subscription Details</CardTitle>
|
|
||||||
{canEditUsers && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setAssignPlanId(subscription?.plan.id ?? "");
|
|
||||||
setIsAssignDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
|
||||||
Assign Plan
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{subscriptionLoading ? (
|
|
||||||
<div className="flex items-center text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Loading subscription...
|
|
||||||
</div>
|
|
||||||
) : subscription ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Plan</p>
|
|
||||||
<p className="font-medium">{subscription.plan.displayName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Status</p>
|
|
||||||
<Badge>{subscription.subscription.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Billing</p>
|
|
||||||
<p className="font-medium">{subscription.subscription.billingInterval}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Period End</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{format(new Date(subscription.subscription.currentPeriodEnd), 'PP')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-3">Usage This Period</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{Object.entries(subscription.usage).map(([key, { used, limit }]) => (
|
|
||||||
<div key={key} className="rounded-lg border p-3">
|
|
||||||
<p className="text-xs text-muted-foreground capitalize">
|
|
||||||
{key.replace(/_/g, ' ')}
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{used} / {limit === null ? '∞' : limit}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground">No subscription data available.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="statistics" className="space-y-4">
|
<TabsContent value="statistics" className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -304,9 +159,7 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
|
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">{user._count?.invoices || 0}</div>
|
||||||
{user._count?.invoices || 0}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -314,9 +167,7 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Reports</CardTitle>
|
<CardTitle className="text-sm font-medium">Reports</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">{user._count?.reports || 0}</div>
|
||||||
{user._count?.reports || 0}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -324,9 +175,7 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Documents</CardTitle>
|
<CardTitle className="text-sm font-medium">Documents</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">{user._count?.documents || 0}</div>
|
||||||
{user._count?.documents || 0}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -334,9 +183,7 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Payments</CardTitle>
|
<CardTitle className="text-sm font-medium">Payments</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">{user._count?.payments || 0}</div>
|
||||||
{user._count?.payments || 0}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,9 +204,7 @@ export default function UserDetailsPage() {
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
value={editForm.firstName}
|
value={editForm.firstName}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
|
||||||
setEditForm({ ...editForm, firstName: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -367,9 +212,7 @@ export default function UserDetailsPage() {
|
||||||
<Input
|
<Input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
value={editForm.lastName}
|
value={editForm.lastName}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
|
||||||
setEditForm({ ...editForm, lastName: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -378,19 +221,12 @@ export default function UserDetailsPage() {
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={editForm.email}
|
value={editForm.email}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
setEditForm({ ...editForm, email: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role">Role</Label>
|
<Label htmlFor="role">Role</Label>
|
||||||
<Select
|
<Select value={editForm.role} onValueChange={(value) => setEditForm({ ...editForm, role: value })}>
|
||||||
value={editForm.role}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setEditForm({ ...editForm, role: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select role" />
|
<SelectValue placeholder="Select role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -405,10 +241,8 @@ export default function UserDetailsPage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="status">Status</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editForm.isActive ? "active" : "inactive"}
|
value={editForm.isActive ? 'active' : 'inactive'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setEditForm({ ...editForm, isActive: value === 'active' })}
|
||||||
setEditForm({ ...editForm, isActive: value === "active" })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder="Select status" />
|
||||||
|
|
@ -421,79 +255,17 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsEditDialogOpen(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveEdit} disabled={isSubmitting}>
|
<Button onClick={handleSaveEdit} disabled={isSubmitting}>
|
||||||
{isSubmitting && (
|
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={isAssignDialogOpen} onOpenChange={setIsAssignDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Assign Subscription Plan</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Manually assign a plan to this user. Changes take effect immediately.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="plan">Plan</Label>
|
|
||||||
<Select value={assignPlanId} onValueChange={setAssignPlanId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a plan" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{plans?.map((plan) => (
|
|
||||||
<SelectItem key={plan.id} value={plan.id}>
|
|
||||||
{plan.displayName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="billingInterval">Billing Interval</Label>
|
|
||||||
<Select
|
|
||||||
value={assignBillingInterval}
|
|
||||||
onValueChange={(value) => setAssignBillingInterval(value as BillingInterval)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select interval" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="MONTHLY">Monthly</SelectItem>
|
|
||||||
<SelectItem value="YEARLY">Yearly</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsAssignDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => assignPlanMutation.mutate()}
|
|
||||||
disabled={!assignPlanId || assignPlanMutation.isPending}
|
|
||||||
>
|
|
||||||
{assignPlanMutation.isPending && (
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Assign Plan
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,232 +1,409 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Search,
|
Table,
|
||||||
Eye,
|
TableBody,
|
||||||
ChevronLeft,
|
TableCell,
|
||||||
ChevronRight,
|
TableHead,
|
||||||
Filter,
|
TableHeader,
|
||||||
Plus,
|
TableRow,
|
||||||
} from "lucide-react";
|
} from "@/components/ui/table"
|
||||||
import { userService } from "@/services";
|
import {
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
Select,
|
||||||
import { format } from "date-fns";
|
SelectContent,
|
||||||
import { cn } from "@/lib/utils";
|
SelectItem,
|
||||||
import { toast } from "sonner";
|
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
|
||||||
|
}
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
const { canCreateUsers } = useAdminRole();
|
const queryClient = useQueryClient()
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1)
|
||||||
const [limit] = useState(15);
|
const [limit] = useState(20)
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("")
|
||||||
const [roleFilter] = useState<string>("all");
|
const [roleFilter, setRoleFilter] = useState<string>("all")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||||
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useQuery({
|
const { data: usersData, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "users", page, limit, search, roleFilter],
|
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: Record<string, string | number | boolean> = { page, limit };
|
const params: Record<string, string | number | boolean> = { page, limit }
|
||||||
if (search) params.search = search;
|
if (search) params.search = search
|
||||||
if (roleFilter !== "all") params.role = roleFilter;
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
return await userService.getUsers(params);
|
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
|
||||||
|
return await userService.getUsers(params)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const getRoleBadgeColor = (role: string) => {
|
const deleteUserMutation = useMutation({
|
||||||
switch (role) {
|
mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
|
||||||
case "ADMIN":
|
userService.deleteUser(id, hard),
|
||||||
return "text-rose-600 bg-rose-50 border-rose-100";
|
onSuccess: () => {
|
||||||
case "USER":
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
return "text-blue-600 bg-blue-50 border-blue-100";
|
toast.success("User deleted successfully")
|
||||||
case "VIEWER":
|
setDeleteDialogOpen(false)
|
||||||
return "text-emerald-600 bg-emerald-50 border-emerald-100";
|
},
|
||||||
default:
|
onError: (error) => {
|
||||||
return "text-gray-600 bg-gray-50 border-gray-100";
|
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<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setImportFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleBadgeVariant = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'destructive'
|
||||||
|
case 'USER':
|
||||||
|
return 'default'
|
||||||
|
case 'VIEWER':
|
||||||
|
return 'secondary'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<h2 className="text-3xl font-bold">Users Management</h2>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<div className="flex gap-2">
|
||||||
Users
|
<Button variant="outline" onClick={() => setImportDialogOpen(true)}>
|
||||||
</h1>
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
<p className="text-gray-500 mt-1">
|
Import Users
|
||||||
Manage system access and permissions.
|
</Button>
|
||||||
</p>
|
<Button>
|
||||||
</div>
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
<div className="flex items-center gap-2">
|
Add User
|
||||||
{canCreateUsers && (
|
</Button>
|
||||||
<Button
|
|
||||||
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
|
|
||||||
onClick={() =>
|
|
||||||
toast.info("User creation module is being synchronized.")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add User
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card>
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
User Directory
|
<CardTitle>All Users</CardTitle>
|
||||||
</CardTitle>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative w-64">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Input
|
||||||
<Input
|
placeholder="Search users..."
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
className="pl-10 w-64"
|
||||||
placeholder="Search email or name..."
|
value={search}
|
||||||
value={search}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All Roles" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Roles</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||||
|
<SelectItem value="USER">User</SelectItem>
|
||||||
|
<SelectItem value="VIEWER">Viewer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" onClick={handleExport}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full text-left">
|
<div className="text-center py-8">Loading users...</div>
|
||||||
<thead className="bg-gray-50 border-b">
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<Table>
|
||||||
User
|
<TableHeader>
|
||||||
</th>
|
<TableRow>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Email</TableHead>
|
||||||
Role
|
<TableHead>Name</TableHead>
|
||||||
</th>
|
<TableHead>Role</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<TableHead>Status</TableHead>
|
||||||
Status
|
<TableHead>Created At</TableHead>
|
||||||
</th>
|
<TableHead>Actions</TableHead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
</TableRow>
|
||||||
Created
|
</TableHeader>
|
||||||
</th>
|
<TableBody>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
{usersData?.data?.map((user: User) => (
|
||||||
Actions
|
<TableRow key={user.id}>
|
||||||
</th>
|
<TableCell className="font-medium">{user.email}</TableCell>
|
||||||
</tr>
|
<TableCell>{user.firstName} {user.lastName}</TableCell>
|
||||||
</thead>
|
<TableCell>
|
||||||
<tbody className="divide-y">
|
<Badge variant={getRoleBadgeVariant(user.role)}>
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
Retrieving user data...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : usersData?.data && usersData.data.length > 0 ? (
|
|
||||||
usersData.data.map((user: any) => (
|
|
||||||
<tr
|
|
||||||
key={user.id}
|
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-gray-900">
|
|
||||||
{user.firstName} {user.lastName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400">
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
|
||||||
getRoleBadgeColor(user.role),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-6 py-4">
|
<TableCell>
|
||||||
<span
|
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||||
className={cn(
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
|
</Badge>
|
||||||
user.isActive
|
</TableCell>
|
||||||
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
<TableCell>
|
||||||
: "text-slate-600 bg-slate-50 border-slate-100",
|
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
|
||||||
)}
|
</TableCell>
|
||||||
>
|
<TableCell>
|
||||||
{user.isActive ? "Active" : "Inactive"}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<Button
|
||||||
</td>
|
variant="ghost"
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
size="icon"
|
||||||
{format(new Date(user.createdAt), "MMM dd, yyyy")}
|
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||||
</td>
|
>
|
||||||
<td className="px-6 py-4 text-right">
|
<Eye className="w-4 h-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-8 w-8 rounded-none"
|
size="icon"
|
||||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
onClick={() => {
|
||||||
>
|
setSelectedUser(user)
|
||||||
<Eye className="w-4 h-4 text-gray-400" />
|
setResetPasswordDialogOpen(true)
|
||||||
</Button>
|
}}
|
||||||
</td>
|
>
|
||||||
</tr>
|
<Key className="w-4 h-4" />
|
||||||
))
|
</Button>
|
||||||
) : (
|
<Button
|
||||||
<tr>
|
variant="ghost"
|
||||||
<td
|
size="icon"
|
||||||
colSpan={5}
|
onClick={() => {
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
setSelectedUser(user)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{usersData?.data?.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{usersData && usersData.total > limit && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, usersData.total)} of {usersData.total} users
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
>
|
>
|
||||||
No users found.
|
Previous
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
<Button
|
||||||
)}
|
variant="outline"
|
||||||
</tbody>
|
size="sm"
|
||||||
</table>
|
onClick={() => setPage(p => p + 1)}
|
||||||
</div>
|
disabled={page * limit >= usersData.total}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{usersData && (
|
</Card>
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
{/* Delete Dialog */}
|
||||||
Showing {(page - 1) * limit + 1} to{" "}
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
{Math.min(page * limit, usersData.total)} of {usersData.total}
|
<DialogContent>
|
||||||
</p>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<DialogTitle>Delete User</DialogTitle>
|
||||||
<Button
|
<DialogDescription>
|
||||||
variant="outline"
|
Are you sure you want to delete {selectedUser?.email}? This action cannot be undone.
|
||||||
size="icon"
|
</DialogDescription>
|
||||||
className="h-8 w-8 rounded-none"
|
</DialogHeader>
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
<DialogFooter>
|
||||||
disabled={page === 1}
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
>
|
Cancel
|
||||||
<ChevronLeft className="w-4 h-4" />
|
</Button>
|
||||||
</Button>
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
<Button
|
Delete
|
||||||
variant="outline"
|
</Button>
|
||||||
size="icon"
|
</DialogFooter>
|
||||||
className="h-8 w-8 rounded-none"
|
</DialogContent>
|
||||||
onClick={() => setPage((p) => p + 1)}
|
</Dialog>
|
||||||
disabled={page * limit >= usersData.total}
|
|
||||||
>
|
{/* Reset Password Dialog */}
|
||||||
<ChevronRight className="w-4 h-4" />
|
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
|
||||||
</Button>
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reset Password</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Reset password for {selectedUser?.email}? A temporary password will be generated.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleResetPassword}>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Import Users Dialog */}
|
||||||
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Users</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a CSV file with user data. The file should contain columns: email, firstName, lastName, role (optional).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="import-file">CSV File</Label>
|
||||||
|
<Input
|
||||||
|
id="import-file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{importFile && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Selected: {importFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DialogFooter>
|
||||||
</Card>
|
<Button variant="outline" onClick={() => {
|
||||||
|
setImportDialogOpen(false)
|
||||||
|
setImportFile(null)
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleImport} disabled={!importFile || importUsersMutation.isPending}>
|
||||||
|
{importUsersMutation.isPending ? "Importing..." : "Import"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Eye, EyeOff } from "lucide-react"
|
import { Eye, EyeOff } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { authService } from "@/services"
|
import { authService } from "@/services"
|
||||||
import { hasPanelAccess } from "@/lib/admin-roles"
|
|
||||||
import { errorTracker } from "@/lib/error-tracker"
|
import { errorTracker } from "@/lib/error-tracker"
|
||||||
import type { ApiError, LocationState } from "@/types/error.types"
|
import type { ApiError, LocationState } from "@/types/error.types"
|
||||||
|
|
||||||
|
|
@ -28,8 +27,9 @@ export default function LoginPage() {
|
||||||
try {
|
try {
|
||||||
const response = await authService.login({ email, password })
|
const response = await authService.login({ email, password })
|
||||||
|
|
||||||
if (!hasPanelAccess(response.user.role)) {
|
// Check if user is admin
|
||||||
toast.error("Access denied. Staff panel credentials required.")
|
if (response.user.role !== 'ADMIN') {
|
||||||
|
toast.error("Access denied. Admin privileges required.")
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,567 +1,277 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
import {
|
||||||
Dialog,
|
Table,
|
||||||
DialogContent,
|
TableBody,
|
||||||
DialogDescription,
|
TableCell,
|
||||||
DialogFooter,
|
TableHead,
|
||||||
DialogHeader,
|
TableHeader,
|
||||||
DialogTitle,
|
TableRow,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/table"
|
||||||
import {
|
import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react"
|
||||||
Search,
|
import { notificationService } from "@/services/notification.service"
|
||||||
CheckCheck,
|
import { toast } from "sonner"
|
||||||
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() {
|
export default function NotificationsPage() {
|
||||||
const { canSendNotifications } = useAdminRole();
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const queryClient = useQueryClient();
|
const [typeFilter, setTypeFilter] = useState("")
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [statusFilter, setStatusFilter] = useState("")
|
||||||
const [activeChannel, setActiveChannel] = useState<Channel>("PUSH");
|
|
||||||
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Combined form state
|
const { data: notifications, isLoading, refetch } = useQuery({
|
||||||
const [pushForm, setPushForm] = useState<SendPushNotificationRequest>({
|
queryKey: ['notifications'],
|
||||||
title: "",
|
|
||||||
body: "",
|
|
||||||
recipientId: "",
|
|
||||||
url: "",
|
|
||||||
icon: "/assets/icon.png",
|
|
||||||
});
|
|
||||||
const [smsForm, setSmsForm] = useState<SendSmsNotificationRequest>({
|
|
||||||
body: "",
|
|
||||||
recipientPhone: "",
|
|
||||||
});
|
|
||||||
const [emailForm, setEmailForm] = useState<SendEmailNotificationRequest>({
|
|
||||||
subject: "",
|
|
||||||
body: "",
|
|
||||||
recipientEmail: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: notifications, isLoading } = useQuery({
|
|
||||||
queryKey: ["notifications"],
|
|
||||||
queryFn: () => notificationService.getNotifications(),
|
queryFn: () => notificationService.getNotifications(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: unreadCount } = useQuery({
|
const { data: unreadCount } = useQuery({
|
||||||
queryKey: ["notifications", "unread-count"],
|
queryKey: ['notifications', 'unread-count'],
|
||||||
queryFn: () => notificationService.getUnreadCount(),
|
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(() => {
|
const filteredNotifications = useMemo(() => {
|
||||||
if (!notifications) return [];
|
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 handleSend = () => {
|
return notifications.filter((notification) => {
|
||||||
if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
|
// Type filter
|
||||||
else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
|
if (typeFilter && notification.type !== typeFilter) return false
|
||||||
else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPending =
|
// Status filter
|
||||||
pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
|
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])
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
try {
|
||||||
|
if (!filteredNotifications || filteredNotifications.length === 0) {
|
||||||
|
toast.error("No notifications to export")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500">
|
<div className="space-y-6">
|
||||||
{/* Header Section */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
<div>
|
||||||
<div className="space-y-1">
|
<h2 className="text-3xl font-bold">Notifications</h2>
|
||||||
<div className="flex items-center gap-2 text-primary mb-1">
|
{unreadCount !== undefined && unreadCount > 0 && (
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
<BellRing className="w-5 h-5" />
|
You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
|
||||||
</div>
|
</p>
|
||||||
<span className="text-xs font-black uppercase tracking-widest opacity-70">
|
)}
|
||||||
Messaging Hub
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
|
|
||||||
Command <span className="text-primary NOT-italic">Center</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
|
||||||
Dispatch multi-channel broadcasts and monitor real-time network
|
|
||||||
telemetry across the Yaltopia mesh.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<div className="flex items-center gap-3">
|
{unreadCount !== undefined && unreadCount > 0 && (
|
||||||
<Button
|
<Button variant="outline" onClick={handleMarkAllAsRead}>
|
||||||
variant="ghost"
|
<CheckCheck className="w-4 h-4 mr-2" />
|
||||||
className="h-12 px-6 rounded-2xl text-slate-400 hover:text-slate-900 hover:bg-slate-100 font-black uppercase text-[10px] tracking-widest transition-all"
|
Mark All as Read
|
||||||
onClick={() => notificationService.markAllAsRead()}
|
</Button>
|
||||||
>
|
)}
|
||||||
<CheckCheck className="w-4 h-4 mr-2" />
|
<Button>
|
||||||
Clear Signal
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Settings
|
||||||
<Button
|
|
||||||
className="h-12 px-8 rounded-2xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
|
||||||
onClick={() => setIsSendModalOpen(true)}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 mr-2" />
|
|
||||||
New Broadcast
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<Card>
|
||||||
{/* Stats / Quick Info */}
|
<CardHeader>
|
||||||
<div className="lg:col-span-1 space-y-6">
|
<div className="flex items-center justify-between">
|
||||||
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-slate-900 text-white rounded-3xl overflow-hidden p-8">
|
<CardTitle>All Notifications</CardTitle>
|
||||||
<div className="space-y-6">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="relative">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
System Status
|
<Input
|
||||||
</span>
|
placeholder="Search notification..."
|
||||||
<Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
|
className="pl-10 w-64"
|
||||||
Active
|
value={searchQuery}
|
||||||
</Badge>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<span className="text-5xl font-black italic tracking-tighter leading-none">
|
|
||||||
{unreadCount ?? 0}
|
|
||||||
</span>
|
|
||||||
<p className="text-slate-400 text-xs font-medium mt-2">
|
|
||||||
Active notifications in current window.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4 border-t border-white/10 pt-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-black tracking-tight">
|
|
||||||
{notifications?.length ?? 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
|
||||||
Push
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center border-x border-white/10 px-2">
|
|
||||||
<div className="text-lg font-black tracking-tight">—</div>
|
|
||||||
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
|
||||||
SMS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-black tracking-tight">—</div>
|
|
||||||
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
|
||||||
Mail
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="alert">Alert</option>
|
||||||
|
<option value="invoice">Invoice</option>
|
||||||
|
<option value="payment">Payment</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="unread">Unread</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" onClick={handleExport}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl p-6">
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4 px-2">
|
|
||||||
Operator Directives
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
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) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex items-center gap-4 p-3 rounded-2xl hover:bg-slate-50 transition-colors group cursor-default"
|
|
||||||
>
|
|
||||||
<div className="p-2.5 bg-slate-100 rounded-xl group-hover:bg-primary/10 transition-colors">
|
|
||||||
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-primary transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-black text-slate-900 tracking-tight">
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-slate-400 font-medium">
|
|
||||||
{item.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History Feed */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
|
||||||
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
|
|
||||||
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
|
|
||||||
Transmission Log
|
|
||||||
</h2>
|
|
||||||
<div className="relative group min-w-[280px]">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
|
||||||
<Input
|
|
||||||
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
|
||||||
placeholder="Search signal history..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto min-h-[400px]">
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-24 bg-slate-100/50 animate-pulse rounded-2xl"
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : filteredNotifications.length ? (
|
|
||||||
filteredNotifications.map((n) => (
|
|
||||||
<div
|
|
||||||
key={n.id}
|
|
||||||
className="p-6 bg-white border border-slate-100 rounded-2xl hover:border-primary/20 hover:shadow-lg hover:shadow-primary/5 transition-all group relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 right-0 w-1 p-1 h-full bg-slate-100 group-hover:bg-primary transition-colors" />
|
|
||||||
<div className="flex items-start justify-between gap-6">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="p-3 bg-slate-50 rounded-xl group-hover:bg-primary/5 transition-colors">
|
|
||||||
<BellRing className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-black text-slate-900 tracking-tight">
|
|
||||||
{n.title}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[9px] font-black uppercase tracking-tighter opacity-50 px-1.5 py-0 border-slate-200"
|
|
||||||
>
|
|
||||||
Push
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 font-medium leading-relaxed max-w-lg">
|
|
||||||
{n.body}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
|
||||||
<span className="text-[10px] font-bold text-slate-400">
|
|
||||||
{format(
|
|
||||||
new Date(n.createdAt),
|
|
||||||
"HH:mm · MMM d, yyyy",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 border-none",
|
|
||||||
n.isSent
|
|
||||||
? "bg-emerald-500 text-white"
|
|
||||||
: "bg-slate-200 text-slate-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{n.isSent ? "Delivered" : "Queued"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="py-24 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
|
||||||
<History className="w-16 h-16" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
|
||||||
Zero Telemetry
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium italic mt-1">
|
|
||||||
No transmissions detected in the current signal
|
|
||||||
range.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dispatch Dialog */}
|
|
||||||
<Dialog open={isSendModalOpen} onOpenChange={setIsSendModalOpen}>
|
|
||||||
<DialogContent className="rounded-3xl max-w-2xl p-0 border-none shadow-2xl overflow-hidden">
|
|
||||||
<div className="p-8 bg-slate-900 text-white overflow-hidden relative">
|
|
||||||
{/* Decorative element */}
|
|
||||||
<div className="absolute -top-12 -right-12 w-48 h-48 bg-primary/20 rounded-full blur-3xl" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="p-2 bg-white/10 rounded-xl">
|
|
||||||
<Target className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
|
||||||
Signal Transmission
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
|
||||||
Dispatch{" "}
|
|
||||||
<span className="text-primary NOT-italic">Broadcast</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
|
||||||
Authoritative platform-wide signal broadcast. Choose delivery
|
|
||||||
channels and construct the payload with precision.
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
<div className="p-8">
|
<CardContent>
|
||||||
<div className="space-y-8">
|
{isLoading ? (
|
||||||
{/* Channel Selector */}
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="space-y-4">
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||||
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
</div>
|
||||||
Select Uplink Channels
|
) : filteredNotifications && filteredNotifications.length > 0 ? (
|
||||||
</Label>
|
<Table>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<TableHeader>
|
||||||
{[
|
<TableRow>
|
||||||
{ id: "PUSH", icon: BellRing, label: "Push Notification" },
|
<TableHead>Notification ID</TableHead>
|
||||||
{ id: "SMS", icon: MessageSquare, label: "SMS Gateway" },
|
<TableHead>Title</TableHead>
|
||||||
{ id: "EMAIL", icon: Mail, label: "Email Relay" },
|
<TableHead>Message</TableHead>
|
||||||
].map((c) => (
|
<TableHead>Type</TableHead>
|
||||||
<button
|
<TableHead>Status</TableHead>
|
||||||
key={c.id}
|
<TableHead>Created Date</TableHead>
|
||||||
type="button"
|
<TableHead>Read Date</TableHead>
|
||||||
onClick={() => setActiveChannel(c.id as Channel)}
|
<TableHead>Action</TableHead>
|
||||||
className={cn(
|
</TableRow>
|
||||||
"flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 transition-all group",
|
</TableHeader>
|
||||||
activeChannel === c.id
|
<TableBody>
|
||||||
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/10"
|
{filteredNotifications.map((notification) => (
|
||||||
: "border-slate-100 bg-white text-slate-400 hover:border-slate-200",
|
<TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}>
|
||||||
|
<TableCell className="font-medium">{notification.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{notification.title}</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate">{notification.message}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">{notification.type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getStatusBadge(notification.isRead)}>
|
||||||
|
{notification.isRead ? 'Read' : 'Unread'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(notification.createdAt)}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(notification.readAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!notification.isRead && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
|
title="Mark as read"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
>
|
</TableCell>
|
||||||
<c.icon
|
</TableRow>
|
||||||
className={cn(
|
))}
|
||||||
"w-6 h-6 transition-transform group-active:scale-90",
|
</TableBody>
|
||||||
activeChannel === c.id
|
</Table>
|
||||||
? "text-primary"
|
) : (
|
||||||
: "text-slate-300",
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
)}
|
{searchQuery || typeFilter || statusFilter
|
||||||
/>
|
? 'No notifications match your filters'
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest">
|
: 'No notifications found'
|
||||||
{c.id}
|
}
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="bg-slate-100" />
|
|
||||||
|
|
||||||
{/* Dynamic Form Area */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{activeChannel === "PUSH" && (
|
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Notification Title
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={pushForm.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPushForm({ ...pushForm, title: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Critical System Patch Available"
|
|
||||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Message Body
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={pushForm.body}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPushForm({ ...pushForm, body: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Update your client to version 4.2 now..."
|
|
||||||
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeChannel === "SMS" && (
|
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Target Phone (Optional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={smsForm.recipientPhone}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSmsForm({
|
|
||||||
...smsForm,
|
|
||||||
recipientPhone: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="+1 (555) 000-0000"
|
|
||||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
|
||||||
/>
|
|
||||||
<p className="text-[9px] text-slate-400 font-medium italic">
|
|
||||||
Leave empty for multi-user broadcast.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
SMS Payload
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={smsForm.body}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSmsForm({ ...smsForm, body: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Your Yaltopia ticket code is XYZ-123..."
|
|
||||||
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeChannel === "EMAIL" && (
|
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
Email Subject
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={emailForm.subject}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEmailForm({
|
|
||||||
...emailForm,
|
|
||||||
subject: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Important Account Update"
|
|
||||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
|
||||||
HTML Content
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={emailForm.body}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEmailForm({ ...emailForm, body: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="<h1>Welcome to Yaltopia</h1>..."
|
|
||||||
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</CardContent>
|
||||||
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
</Card>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
|
||||||
onClick={() => setIsSendModalOpen(false)}
|
|
||||||
>
|
|
||||||
Abort Mission
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-12 px-10 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-slate-200 transition-all active:scale-95"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={handleSend}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Broadcasting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Commit {activeChannel} Signal
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,87 @@
|
||||||
import axios, {
|
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||||
type AxiosInstance,
|
|
||||||
type AxiosError,
|
|
||||||
type InternalAxiosRequestConfig,
|
|
||||||
} from "axios";
|
|
||||||
|
|
||||||
const useDevApiProxy =
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
|
||||||
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 {
|
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
_retry?: boolean;
|
_retry?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create axios instance with default config
|
// Create axios instance with default config
|
||||||
const apiClient: AxiosInstance = axios.create({
|
const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
withCredentials: true, // Send cookies with requests
|
withCredentials: true, // Send cookies with requests
|
||||||
timeout: 30000, // 30 second timeout
|
timeout: 30000, // 30 second timeout
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
serialize: (params) => {
|
serialize: (params) => {
|
||||||
// Custom serializer to preserve number types
|
// Custom serializer to preserve number types
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams()
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
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
|
// Request interceptor - Add auth token
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
// Add token from localStorage as fallback (cookies are preferred)
|
// Add token from localStorage as fallback (cookies are preferred)
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem('access_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config;
|
return config
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error)
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
// Response interceptor - Handle errors and token refresh
|
// Response interceptor - Handle errors and token refresh
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as RetryableAxiosRequestConfig;
|
const originalRequest = error.config as RetryableAxiosRequestConfig
|
||||||
|
|
||||||
// Handle 401 Unauthorized - Try to refresh token
|
// Handle 401 Unauthorized - Try to refresh token
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to refresh token
|
// Try to refresh token
|
||||||
const refreshToken = localStorage.getItem("refresh_token");
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_BASE_URL}/auth/refresh`,
|
`${API_BASE_URL}/auth/refresh`,
|
||||||
{ refreshToken },
|
{ refreshToken },
|
||||||
{ withCredentials: true },
|
{ withCredentials: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
const { accessToken } = response.data;
|
const { accessToken } = response.data
|
||||||
localStorage.setItem("access_token", accessToken);
|
localStorage.setItem('access_token', accessToken)
|
||||||
|
|
||||||
// Retry original request with new token
|
// Retry original request with new token
|
||||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||||
return apiClient(originalRequest);
|
return apiClient(originalRequest)
|
||||||
}
|
}
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
// Refresh failed - logout user
|
// Refresh failed - logout user
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem("refresh_token");
|
localStorage.removeItem('refresh_token')
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem('user')
|
||||||
window.location.href = "/login";
|
window.location.href = '/login'
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error)
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import apiClient from './api/client'
|
import apiClient from './api/client'
|
||||||
import { hasPanelAccess } from '@/lib/admin-roles'
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -89,11 +88,11 @@ class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy: true for any staff panel role
|
* Check if user is admin
|
||||||
*/
|
*/
|
||||||
isAdmin(): boolean {
|
isAdmin(): boolean {
|
||||||
const user = this.getCurrentUser()
|
const user = this.getCurrentUser()
|
||||||
return hasPanelAccess(user?.role)
|
return user?.role === 'ADMIN'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,21 @@
|
||||||
import apiClient from "./api/client";
|
import apiClient from './api/client'
|
||||||
import type { ActivityLog } from "@/types/activity.types";
|
import type { ActivityLog } from '@/types/activity.types'
|
||||||
|
|
||||||
export interface UserDashboardStats {
|
export interface UserDashboardStats {
|
||||||
totalInvoices: number;
|
totalInvoices: number
|
||||||
totalTransactions: number;
|
totalTransactions: number
|
||||||
totalRevenue: number;
|
totalRevenue: number
|
||||||
pendingInvoices: number;
|
pendingInvoices: number
|
||||||
growthPercentage?: number;
|
growthPercentage?: number
|
||||||
recentActivity?: ActivityLog[];
|
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<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
export interface UserProfile {
|
||||||
id: string;
|
id: string
|
||||||
email: string;
|
email: string
|
||||||
firstName: string;
|
firstName: string
|
||||||
lastName: string;
|
lastName: string
|
||||||
role: string;
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class DashboardService {
|
class DashboardService {
|
||||||
|
|
@ -59,76 +23,37 @@ class DashboardService {
|
||||||
* Get current user profile
|
* Get current user profile
|
||||||
*/
|
*/
|
||||||
async getUserProfile(): Promise<UserProfile> {
|
async getUserProfile(): Promise<UserProfile> {
|
||||||
const response = await apiClient.get<UserProfile>("/user/profile");
|
const response = await apiClient.get<UserProfile>('/user/profile')
|
||||||
return response.data;
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user dashboard statistics
|
* Get user dashboard statistics
|
||||||
*/
|
*/
|
||||||
async getUserStats(): Promise<UserDashboardStats> {
|
async getUserStats(): Promise<UserDashboardStats> {
|
||||||
const response = await apiClient.get<UserDashboardStats>("/user/stats");
|
const response = await apiClient.get<UserDashboardStats>('/user/stats')
|
||||||
return response.data;
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user recent activity
|
* Get user recent activity
|
||||||
*/
|
*/
|
||||||
async getRecentActivity(limit: number = 10): Promise<ActivityLog[]> {
|
async getRecentActivity(limit: number = 10): Promise<ActivityLog[]> {
|
||||||
const response = await apiClient.get<ActivityLog[]>("/user/activity", {
|
const response = await apiClient.get<ActivityLog[]>('/user/activity', {
|
||||||
params: { limit },
|
params: { limit },
|
||||||
});
|
})
|
||||||
return response.data;
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export user dashboard data
|
* Export user dashboard data
|
||||||
*/
|
*/
|
||||||
async exportData(): Promise<Blob> {
|
async exportData(): Promise<Blob> {
|
||||||
const response = await apiClient.get("/user/export", {
|
const response = await apiClient.get('/user/export', {
|
||||||
responseType: "blob",
|
responseType: 'blob',
|
||||||
});
|
})
|
||||||
return response.data;
|
return response.data
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get main dashboard metrics
|
|
||||||
*/
|
|
||||||
async getMetrics(): Promise<DashboardMetrics> {
|
|
||||||
const response =
|
|
||||||
await apiClient.get<DashboardMetrics>("/dashboard/metrics");
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scanned invoices pending verification
|
|
||||||
*/
|
|
||||||
async getScannedInvoices(): Promise<ScannedInvoice[]> {
|
|
||||||
const response = await apiClient.get<ScannedInvoice[]>(
|
|
||||||
"/dashboard/scanned-invoices",
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get sales vs purchase comparison
|
|
||||||
*/
|
|
||||||
async getSalesPurchaseComparison(): Promise<SalesPurchaseComparison> {
|
|
||||||
const response = await apiClient.get<SalesPurchaseComparison>(
|
|
||||||
"/dashboard/sales-purchase",
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get invoice status breakdown
|
|
||||||
*/
|
|
||||||
async getInvoiceStatusBreakdown(): Promise<InvoiceStatusBreakdown[]> {
|
|
||||||
const response = await apiClient.get<InvoiceStatusBreakdown[]>(
|
|
||||||
"/dashboard/invoice-status",
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dashboardService = new DashboardService();
|
export const dashboardService = new DashboardService()
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import apiClient from './api/client'
|
|
||||||
import type {
|
|
||||||
EmailPreviewResult,
|
|
||||||
EmailTemplateListResponse,
|
|
||||||
} from '@/types/email.types'
|
|
||||||
|
|
||||||
class EmailService {
|
|
||||||
async listPreviewTemplates(): Promise<EmailTemplateListResponse> {
|
|
||||||
const response = await apiClient.get<EmailTemplateListResponse>('/emails/preview/templates')
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPreviewJson(templateKey: string): Promise<EmailPreviewResult> {
|
|
||||||
const response = await apiClient.get<EmailPreviewResult>(
|
|
||||||
`/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()
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import apiClient from "./api/client"
|
|
||||||
|
|
||||||
/** Who can see this FAQ entry in product surfaces */
|
|
||||||
export type FaqAudience = "END_USER" | "SYSTEM_USER" | "ALL"
|
|
||||||
|
|
||||||
export interface FaqEntry {
|
|
||||||
id: string
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
audience: FaqAudience
|
|
||||||
sortOrder: number
|
|
||||||
isPublished: boolean
|
|
||||||
createdAt: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedFaqs {
|
|
||||||
data: FaqEntry[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
totalPages: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class FaqService {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
audience?: FaqAudience
|
|
||||||
search?: string
|
|
||||||
}): Promise<PaginatedFaqs> {
|
|
||||||
const response = await apiClient.get<PaginatedFaqs>("/admin/faq", {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: {
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
audience: FaqAudience
|
|
||||||
sortOrder?: number
|
|
||||||
isPublished?: boolean
|
|
||||||
}): Promise<FaqEntry> {
|
|
||||||
const response = await apiClient.post<FaqEntry>("/admin/faq", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
id: string,
|
|
||||||
data: Partial<
|
|
||||||
Pick<
|
|
||||||
FaqEntry,
|
|
||||||
"question" | "answer" | "audience" | "sortOrder" | "isPublished"
|
|
||||||
>
|
|
||||||
>,
|
|
||||||
): Promise<FaqEntry> {
|
|
||||||
const response = await apiClient.patch<FaqEntry>(`/admin/faq/${id}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/admin/faq/${id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const faqService = new FaqService()
|
|
||||||
|
|
@ -1,92 +1,23 @@
|
||||||
// Export all services from a single entry point
|
// Export all services from a single entry point
|
||||||
export { authService } from "./auth.service";
|
export { authService } from './auth.service'
|
||||||
export { userService } from "./user.service";
|
export { userService } from './user.service'
|
||||||
export { analyticsService } from "./analytics.service";
|
export { analyticsService } from './analytics.service'
|
||||||
export { securityService } from "./security.service";
|
export { securityService } from './security.service'
|
||||||
export { systemService } from "./system.service";
|
export { systemService } from './system.service'
|
||||||
export { announcementService } from "./announcement.service";
|
export { announcementService } from './announcement.service'
|
||||||
export { auditService } from "./audit.service";
|
export { auditService } from './audit.service'
|
||||||
export { settingsService } from "./settings.service";
|
export { settingsService } from './settings.service'
|
||||||
export { dashboardService } from "./dashboard.service";
|
export { dashboardService } from './dashboard.service'
|
||||||
export { notificationService } from "./notification.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 types
|
||||||
export type { LoginRequest, LoginResponse } from "./auth.service";
|
export type { LoginRequest, LoginResponse } from './auth.service'
|
||||||
export type { User, GetUsersParams, PaginatedResponse } from "./user.service";
|
export type { User, GetUsersParams, PaginatedResponse } from './user.service'
|
||||||
export type {
|
export type { OverviewStats, UserGrowthData, RevenueData } from './analytics.service'
|
||||||
OverviewStats,
|
export type { SuspiciousActivity, ActiveSession, FailedLogin, ApiKey } from './security.service'
|
||||||
UserGrowthData,
|
export type { HealthStatus, SystemInfo, MaintenanceStatus } from './system.service'
|
||||||
RevenueData,
|
export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } from './announcement.service'
|
||||||
} from "./analytics.service";
|
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
|
||||||
export type {
|
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
|
||||||
SuspiciousActivity,
|
export type { UserDashboardStats, UserProfile } from './dashboard.service'
|
||||||
ActiveSession,
|
export type { Notification, NotificationSettings } from './notification.service'
|
||||||
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";
|
|
||||||
|
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
import apiClient from "./api/client";
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
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<string, any>;
|
|
||||||
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<PaginatedResponse<Invoice>> {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Invoice>>(
|
|
||||||
"/invoices",
|
|
||||||
{
|
|
||||||
params: filters,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new invoice
|
|
||||||
*/
|
|
||||||
async createInvoice(data: any): Promise<Invoice> {
|
|
||||||
const response = await apiClient.post<Invoice>("/invoices", data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing invoice
|
|
||||||
*/
|
|
||||||
async updateInvoice(id: string, data: any): Promise<Invoice> {
|
|
||||||
const response = await apiClient.put<Invoice>(`/invoices/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an invoice
|
|
||||||
*/
|
|
||||||
async deleteInvoice(id: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/invoices/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all proforma invoices
|
|
||||||
*/
|
|
||||||
async getProformas(
|
|
||||||
filters: ProformaFilters = {},
|
|
||||||
): Promise<PaginatedResponse<Proforma>> {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Proforma>>(
|
|
||||||
"/proforma",
|
|
||||||
{
|
|
||||||
params: filters,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new proforma invoice
|
|
||||||
*/
|
|
||||||
async createProforma(data: any): Promise<Proforma> {
|
|
||||||
const response = await apiClient.post<Proforma>("/proforma", data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing proforma invoice
|
|
||||||
*/
|
|
||||||
async updateProforma(id: string, data: any): Promise<Proforma> {
|
|
||||||
const response = await apiClient.put<Proforma>(`/proforma/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a proforma invoice
|
|
||||||
*/
|
|
||||||
async deleteProforma(id: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/proforma/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all proforma requests (admin view)
|
|
||||||
*/
|
|
||||||
async getProformaRequests(
|
|
||||||
filters: ProformaRequestFilters = {},
|
|
||||||
): Promise<PaginatedResponse<ProformaRequest>> {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<ProformaRequest>>(
|
|
||||||
"/admin/proforma-requests",
|
|
||||||
{
|
|
||||||
params: filters,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get proforma request details (admin view)
|
|
||||||
*/
|
|
||||||
async getProformaRequestDetails(id: string): Promise<ProformaRequest> {
|
|
||||||
const response = await apiClient.get<ProformaRequest>(
|
|
||||||
`/admin/proforma-requests/${id}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Create a new proforma request
|
|
||||||
*/
|
|
||||||
async createProformaRequest(data: any): Promise<ProformaRequest> {
|
|
||||||
const response = await apiClient.post<ProformaRequest>(
|
|
||||||
"/proforma-requests",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Update an existing proforma request
|
|
||||||
*/
|
|
||||||
async updateProformaRequest(id: string, data: any): Promise<ProformaRequest> {
|
|
||||||
const response = await apiClient.put<ProformaRequest>(
|
|
||||||
`/proforma-requests/${id}`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Close a proforma request
|
|
||||||
*/
|
|
||||||
async closeProformaRequest(id: string): Promise<void> {
|
|
||||||
await apiClient.post(`/proforma-requests/${id}/close`);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Cancel a proforma request
|
|
||||||
*/
|
|
||||||
async cancelProformaRequest(id: string): Promise<void> {
|
|
||||||
await apiClient.post(`/proforma-requests/${id}/cancel`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const invoiceService = new InvoiceService();
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import apiClient from "./api/client"
|
|
||||||
|
|
||||||
export type IssueStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "CLOSED"
|
|
||||||
export type IssueReporterType = "USER" | "SYSTEM_USER"
|
|
||||||
|
|
||||||
export interface SupportIssue {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
status: IssueStatus
|
|
||||||
reporterType: IssueReporterType
|
|
||||||
reporterEmail: string
|
|
||||||
reporterUserId?: string
|
|
||||||
priority: "LOW" | "MEDIUM" | "HIGH"
|
|
||||||
createdAt: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IssueFilters {
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
status?: IssueStatus
|
|
||||||
search?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedIssues {
|
|
||||||
data: SupportIssue[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
totalPages: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class IssueService {
|
|
||||||
async list(filters: IssueFilters = {}): Promise<PaginatedIssues> {
|
|
||||||
const response = await apiClient.get<PaginatedIssues>("/admin/issues", {
|
|
||||||
params: filters,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
priority?: SupportIssue["priority"]
|
|
||||||
}): Promise<SupportIssue> {
|
|
||||||
const response = await apiClient.post<SupportIssue>("/admin/issues", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateStatus(
|
|
||||||
id: string,
|
|
||||||
status: IssueStatus,
|
|
||||||
): Promise<SupportIssue> {
|
|
||||||
const response = await apiClient.patch<SupportIssue>(
|
|
||||||
`/admin/issues/${id}`,
|
|
||||||
{ status },
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const issueService = new IssueService()
|
|
||||||
|
|
@ -1,175 +1,112 @@
|
||||||
import apiClient from "./api/client";
|
import apiClient from './api/client'
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string
|
||||||
title: string;
|
title: string
|
||||||
body: string;
|
message: string
|
||||||
icon?: string;
|
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment'
|
||||||
url?: string;
|
recipient: string
|
||||||
sentAt?: string;
|
status: 'sent' | 'delivered' | 'read' | 'unread'
|
||||||
scheduledFor?: string;
|
isRead: boolean
|
||||||
isSent: boolean;
|
createdAt: string
|
||||||
recipientId: string;
|
sentAt?: string
|
||||||
data?: Record<string, any>;
|
readAt?: string
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
emailNotifications: boolean;
|
emailNotifications: boolean
|
||||||
pushNotifications: boolean;
|
pushNotifications: boolean
|
||||||
invoiceReminders: boolean;
|
invoiceReminders: boolean
|
||||||
paymentAlerts: boolean;
|
paymentAlerts: boolean
|
||||||
systemUpdates: boolean;
|
systemUpdates: boolean
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendPushNotificationRequest {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
icon?: string;
|
|
||||||
url?: string;
|
|
||||||
recipientId?: string;
|
|
||||||
scheduledFor?: string;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
class NotificationService {
|
||||||
/**
|
/**
|
||||||
* Get all notifications for current user (Paginated)
|
* Get all notifications for current user
|
||||||
*/
|
*/
|
||||||
async getNotifications(params?: {
|
async getNotifications(params?: {
|
||||||
page?: number;
|
type?: string
|
||||||
limit?: number;
|
status?: string
|
||||||
type?: string;
|
search?: string
|
||||||
status?: string;
|
|
||||||
search?: string;
|
|
||||||
}): Promise<Notification[]> {
|
}): Promise<Notification[]> {
|
||||||
const response = await apiClient.get<Notification[]>("/notifications", {
|
const response = await apiClient.get<Notification[]>('/notifications', {
|
||||||
params,
|
params,
|
||||||
});
|
})
|
||||||
return response.data;
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread notification count
|
* Get unread notification count
|
||||||
*/
|
*/
|
||||||
async getUnreadCount(): Promise<number> {
|
async getUnreadCount(): Promise<number> {
|
||||||
const response = await apiClient.get<{ count: number }>(
|
const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
|
||||||
"/notifications/unread-count",
|
return response.data.count
|
||||||
);
|
|
||||||
return response.data.count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark notification as read
|
* Mark notification as read
|
||||||
*/
|
*/
|
||||||
async markAsRead(id: string): Promise<void> {
|
async markAsRead(id: string): Promise<void> {
|
||||||
await apiClient.post(`/notifications/${id}/read`);
|
await apiClient.post(`/notifications/${id}/read`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all notifications as read
|
* Mark all notifications as read
|
||||||
*/
|
*/
|
||||||
async markAllAsRead(): Promise<void> {
|
async markAllAsRead(): Promise<void> {
|
||||||
await apiClient.post("/notifications/read-all");
|
await apiClient.post('/notifications/read-all')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send push notification (ADMIN only)
|
* Send notification (ADMIN only)
|
||||||
*/
|
*/
|
||||||
async sendPushNotification(
|
async sendNotification(data: {
|
||||||
data: SendPushNotificationRequest,
|
title: string
|
||||||
): Promise<Notification> {
|
message: string
|
||||||
const response = await apiClient.post<Notification>(
|
type: string
|
||||||
"/admin/notifications/send-push",
|
recipient?: string
|
||||||
data,
|
recipientType?: 'user' | 'all'
|
||||||
);
|
}): Promise<Notification> {
|
||||||
return response.data;
|
const response = await apiClient.post<Notification>('/notifications/send', 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
|
* Subscribe to push notifications
|
||||||
*/
|
*/
|
||||||
async subscribeToPush(subscription: PushSubscription): Promise<void> {
|
async subscribeToPush(subscription: PushSubscription): Promise<void> {
|
||||||
await apiClient.post("/notifications/subscribe", subscription);
|
await apiClient.post('/notifications/subscribe', subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribe from push notifications
|
* Unsubscribe from push notifications
|
||||||
*/
|
*/
|
||||||
async unsubscribeFromPush(endpoint: string): Promise<void> {
|
async unsubscribeFromPush(endpoint: string): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
|
||||||
`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get notification settings
|
* Get notification settings
|
||||||
*/
|
*/
|
||||||
async getSettings(): Promise<NotificationSettings> {
|
async getSettings(): Promise<NotificationSettings> {
|
||||||
const response = await apiClient.get<NotificationSettings>(
|
const response = await apiClient.get<NotificationSettings>('/notifications/settings')
|
||||||
"/notifications/settings",
|
return response.data
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update notification settings
|
* Update notification settings
|
||||||
*/
|
*/
|
||||||
async updateSettings(
|
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
|
||||||
settings: Partial<NotificationSettings>,
|
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings)
|
||||||
): Promise<NotificationSettings> {
|
return response.data
|
||||||
const response = await apiClient.put<NotificationSettings>(
|
|
||||||
"/notifications/settings",
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send invoice reminder
|
* Send invoice reminder
|
||||||
*/
|
*/
|
||||||
async sendInvoiceReminder(invoiceId: string): Promise<void> {
|
async sendInvoiceReminder(invoiceId: string): Promise<void> {
|
||||||
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`);
|
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -177,21 +114,20 @@ class NotificationService {
|
||||||
*/
|
*/
|
||||||
async exportNotifications(notifications: Notification[]): Promise<Blob> {
|
async exportNotifications(notifications: Notification[]): Promise<Blob> {
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
["ID", "Title", "Body", "Status", "Created Date", "Sent Date"],
|
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'],
|
||||||
...notifications.map((n) => [
|
...notifications.map(n => [
|
||||||
n.id,
|
n.id,
|
||||||
n.title,
|
n.title,
|
||||||
n.body,
|
n.message,
|
||||||
n.isSent ? "Sent" : "Scheduled",
|
n.type,
|
||||||
|
n.status,
|
||||||
n.createdAt,
|
n.createdAt,
|
||||||
n.sentAt || "-",
|
n.readAt || '-'
|
||||||
]),
|
])
|
||||||
]
|
].map(row => row.join(',')).join('\n')
|
||||||
.map((row) => row.join(","))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notificationService = new NotificationService();
|
export const notificationService = new NotificationService()
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
import apiClient from "./api/client";
|
|
||||||
|
|
||||||
export interface Payment {
|
|
||||||
id: string;
|
|
||||||
transactionId: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
paymentDate: string;
|
|
||||||
paymentMethod: string;
|
|
||||||
notes: string;
|
|
||||||
isFlagged: boolean;
|
|
||||||
flagReason: string;
|
|
||||||
flagNotes: string;
|
|
||||||
receiptPath: string;
|
|
||||||
senderName: string;
|
|
||||||
senderId: string;
|
|
||||||
receiverName: string;
|
|
||||||
receiverId: string;
|
|
||||||
userId: string;
|
|
||||||
invoiceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentRequest {
|
|
||||||
id: string;
|
|
||||||
paymentRequestNumber: string;
|
|
||||||
customerName: string;
|
|
||||||
customerEmail: string;
|
|
||||||
customerPhone: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
issueDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
description: string;
|
|
||||||
notes: string;
|
|
||||||
taxAmount: number;
|
|
||||||
discountAmount: number;
|
|
||||||
openedCount: number;
|
|
||||||
copiedAccountCount: number;
|
|
||||||
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
|
|
||||||
paymentId: string;
|
|
||||||
accounts: {
|
|
||||||
bankName: string;
|
|
||||||
accountName: string;
|
|
||||||
accountNumber: string;
|
|
||||||
currency: string;
|
|
||||||
}[];
|
|
||||||
pdfPath: string;
|
|
||||||
userId: string;
|
|
||||||
customerId?: string;
|
|
||||||
items: {
|
|
||||||
id?: string;
|
|
||||||
description: string;
|
|
||||||
quantity: number;
|
|
||||||
unitPrice: number;
|
|
||||||
total: number;
|
|
||||||
}[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
meta: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasNextPage: boolean;
|
|
||||||
hasPreviousPage: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentFilters {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
invoiceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentRequestFilters {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
customerName?: string;
|
|
||||||
paymentRequestNumber?: string;
|
|
||||||
status?: string;
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PaymentService {
|
|
||||||
/**
|
|
||||||
* Get all payments
|
|
||||||
*/
|
|
||||||
async getPayments(
|
|
||||||
filters: PaymentFilters = {},
|
|
||||||
): Promise<PaginatedResponse<Payment>> {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Payment>>(
|
|
||||||
"/payments",
|
|
||||||
{
|
|
||||||
params: filters,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new payment
|
|
||||||
*/
|
|
||||||
async createPayment(data: any): Promise<Payment> {
|
|
||||||
const response = await apiClient.post<Payment>("/payments", data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing payment
|
|
||||||
*/
|
|
||||||
async updatePayment(id: string, data: any): Promise<Payment> {
|
|
||||||
const response = await apiClient.put<Payment>(`/payments/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a payment
|
|
||||||
*/
|
|
||||||
async deletePayment(id: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/payments/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get payment requests
|
|
||||||
*/
|
|
||||||
async getPaymentRequests(
|
|
||||||
filters: PaymentRequestFilters = {},
|
|
||||||
): Promise<PaginatedResponse<PaymentRequest>> {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<PaymentRequest>>(
|
|
||||||
"/payment-requests",
|
|
||||||
{
|
|
||||||
params: filters,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new payment request
|
|
||||||
*/
|
|
||||||
async createPaymentRequest(data: any): Promise<PaymentRequest> {
|
|
||||||
const response = await apiClient.post<PaymentRequest>(
|
|
||||||
"/payment-requests",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing payment request
|
|
||||||
*/
|
|
||||||
async updatePaymentRequest(id: string, data: any): Promise<PaymentRequest> {
|
|
||||||
const response = await apiClient.put<PaymentRequest>(
|
|
||||||
`/payment-requests/${id}`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a payment request
|
|
||||||
*/
|
|
||||||
async deletePaymentRequest(id: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/payment-requests/${id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const paymentService = new PaymentService();
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import apiClient from "./api/client"
|
|
||||||
|
|
||||||
export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING"
|
|
||||||
|
|
||||||
export interface SubscriptionTransaction {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
userEmail: string
|
|
||||||
planName: string
|
|
||||||
amount: number
|
|
||||||
currency: string
|
|
||||||
status: SubscriptionPaymentStatus
|
|
||||||
provider: string
|
|
||||||
providerRef?: string
|
|
||||||
failureReason?: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionTransactionFilters {
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
status?: SubscriptionPaymentStatus
|
|
||||||
search?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedSubscriptionTx {
|
|
||||||
data: SubscriptionTransaction[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
totalPages: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class SubscriptionTransactionService {
|
|
||||||
async getTransactions(
|
|
||||||
filters: SubscriptionTransactionFilters = {},
|
|
||||||
): Promise<PaginatedSubscriptionTx> {
|
|
||||||
const response = await apiClient.get<PaginatedSubscriptionTx>(
|
|
||||||
"/subscription/admin/transactions",
|
|
||||||
{ params: filters },
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const subscriptionTransactionService =
|
|
||||||
new SubscriptionTransactionService()
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import apiClient from './api/client'
|
|
||||||
import type {
|
|
||||||
AssignPlanData,
|
|
||||||
GetSubscriptionTransactionsParams,
|
|
||||||
PlanFeatures,
|
|
||||||
SubscriptionPlan,
|
|
||||||
SubscriptionTransactionsResponse,
|
|
||||||
UpdatePlanData,
|
|
||||||
UserSubscriptionSummary,
|
|
||||||
} from '@/types/subscription.types'
|
|
||||||
|
|
||||||
class SubscriptionService {
|
|
||||||
async getAdminPlans(): Promise<SubscriptionPlan[]> {
|
|
||||||
const response = await apiClient.get<SubscriptionPlan[]>('/subscription/admin/plans')
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAdminPlan(id: string): Promise<SubscriptionPlan> {
|
|
||||||
const response = await apiClient.get<SubscriptionPlan>(`/subscription/admin/plans/${id}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePlan(id: string, data: UpdatePlanData): Promise<SubscriptionPlan> {
|
|
||||||
const response = await apiClient.put<SubscriptionPlan>(`/subscription/admin/plans/${id}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePlanFeatures(id: string, features: PlanFeatures): Promise<SubscriptionPlan> {
|
|
||||||
const response = await apiClient.put<SubscriptionPlan>(
|
|
||||||
`/subscription/admin/plans/${id}/features`,
|
|
||||||
features,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserSubscription(userId: string): Promise<UserSubscriptionSummary> {
|
|
||||||
const response = await apiClient.get<UserSubscriptionSummary>(
|
|
||||||
`/subscription/admin/users/${userId}`,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async assignPlan(userId: string, data: AssignPlanData) {
|
|
||||||
const response = await apiClient.post(`/subscription/admin/users/${userId}/assign`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSubscriptionTransactions(
|
|
||||||
params?: GetSubscriptionTransactionsParams,
|
|
||||||
): Promise<SubscriptionTransactionsResponse> {
|
|
||||||
const queryParams = params
|
|
||||||
? {
|
|
||||||
...params,
|
|
||||||
page: params.page ? Number(params.page) : undefined,
|
|
||||||
limit: params.limit ? Number(params.limit) : undefined,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const response = await apiClient.get<SubscriptionTransactionsResponse>(
|
|
||||||
'/subscription/admin/transactions',
|
|
||||||
{ params: queryParams },
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const subscriptionService = new SubscriptionService()
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import apiClient from "./api/client"
|
|
||||||
|
|
||||||
/** Internal staff that operate the admin / support panel */
|
|
||||||
export interface SystemMember {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
/** Panel role: SUPER_ADMIN | ADMIN | CUSTOMER_SUPPORT */
|
|
||||||
role: string
|
|
||||||
isActive: boolean
|
|
||||||
createdAt: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateSystemMemberPayload {
|
|
||||||
email: string
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
password: string
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedSystemMembers {
|
|
||||||
data: SystemMember[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
totalPages: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class SystemMemberService {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
search?: string
|
|
||||||
}): Promise<PaginatedSystemMembers> {
|
|
||||||
const response = await apiClient.get<PaginatedSystemMembers>(
|
|
||||||
"/admin/system-members",
|
|
||||||
{ params },
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: CreateSystemMemberPayload): Promise<SystemMember> {
|
|
||||||
const response = await apiClient.post<SystemMember>(
|
|
||||||
"/admin/system-members",
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
id: string,
|
|
||||||
data: Partial<Pick<SystemMember, "firstName" | "lastName" | "role" | "isActive">>,
|
|
||||||
): Promise<SystemMember> {
|
|
||||||
const response = await apiClient.patch<SystemMember>(
|
|
||||||
`/admin/system-members/${id}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const systemMemberService = new SystemMemberService()
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
export interface EmailTemplateMeta {
|
|
||||||
key: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
defaultSubject: string
|
|
||||||
previewUrl: string
|
|
||||||
previewJsonUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailTemplateListResponse {
|
|
||||||
templates: EmailTemplateMeta[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailPreviewResult {
|
|
||||||
templateKey: string
|
|
||||||
subject: string
|
|
||||||
html: string
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
export type BillingInterval = 'MONTHLY' | 'YEARLY'
|
|
||||||
|
|
||||||
export type SubscriptionStatus =
|
|
||||||
| 'ACTIVE'
|
|
||||||
| 'PAST_DUE'
|
|
||||||
| 'CANCELLED'
|
|
||||||
| 'EXPIRED'
|
|
||||||
| 'TRIALING'
|
|
||||||
|
|
||||||
export type ChapaTransactionStatus =
|
|
||||||
| 'PENDING'
|
|
||||||
| 'SUCCESS'
|
|
||||||
| 'FAILED'
|
|
||||||
| 'CANCELLED'
|
|
||||||
|
|
||||||
export interface PlanFeatures {
|
|
||||||
features: Record<string, boolean>
|
|
||||||
limits: Record<string, number | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionPlan {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
displayName: string
|
|
||||||
description: string
|
|
||||||
isFree: boolean
|
|
||||||
isActive: boolean
|
|
||||||
sortOrder: number
|
|
||||||
monthlyPrice: number
|
|
||||||
yearlyPrice: number
|
|
||||||
features: PlanFeatures
|
|
||||||
createdAt?: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePlanData {
|
|
||||||
displayName?: string
|
|
||||||
description?: string
|
|
||||||
monthlyPrice?: number
|
|
||||||
yearlyPrice?: number
|
|
||||||
isActive?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssignPlanData {
|
|
||||||
planId: string
|
|
||||||
billingInterval?: BillingInterval
|
|
||||||
externalRef?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSubscriptionSummary {
|
|
||||||
plan: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
displayName: string
|
|
||||||
isFree: boolean
|
|
||||||
}
|
|
||||||
subscription: {
|
|
||||||
id: string
|
|
||||||
status: SubscriptionStatus
|
|
||||||
billingInterval: BillingInterval
|
|
||||||
currentPeriodStart: string
|
|
||||||
currentPeriodEnd: string
|
|
||||||
nextBillingAt: string | null
|
|
||||||
trialEndsAt: string | null
|
|
||||||
}
|
|
||||||
features: Record<string, boolean>
|
|
||||||
usage: Record<string, { used: number; limit: number | null }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionTransactionUser {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
firstName: string | null
|
|
||||||
lastName: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionTransactionPlan {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
displayName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionTransaction {
|
|
||||||
id: string
|
|
||||||
txRef: string
|
|
||||||
checkoutUrl: string | null
|
|
||||||
totalAmount: number
|
|
||||||
currency: string
|
|
||||||
status: ChapaTransactionStatus
|
|
||||||
purpose: string
|
|
||||||
billingInterval: BillingInterval | null
|
|
||||||
userId: string
|
|
||||||
planId: string | null
|
|
||||||
createdAt: string
|
|
||||||
user: SubscriptionTransactionUser
|
|
||||||
plan: SubscriptionTransactionPlan | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionTransactionsResponse {
|
|
||||||
data: SubscriptionTransaction[]
|
|
||||||
meta: {
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
totalPages: number
|
|
||||||
hasNextPage: boolean
|
|
||||||
hasPreviousPage: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubscriptionTransactionsParams {
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
userId?: string
|
|
||||||
planId?: string
|
|
||||||
status?: ChapaTransactionStatus
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +1,37 @@
|
||||||
import { defineConfig, loadEnv } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig({
|
||||||
const env = loadEnv(mode, process.cwd(), "")
|
plugins: [react()],
|
||||||
const useApiProxy = env.VITE_USE_API_PROXY === "true"
|
resolve: {
|
||||||
const proxyTarget =
|
alias: {
|
||||||
env.VITE_PROXY_TARGET ||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
env.VITE_BACKEND_API_URL ||
|
|
||||||
"http://localhost:3001"
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
build: {
|
},
|
||||||
sourcemap: false,
|
build: {
|
||||||
rollupOptions: {
|
sourcemap: false,
|
||||||
output: {
|
rollupOptions: {
|
||||||
manualChunks: {
|
output: {
|
||||||
"react-vendor": ["react", "react-dom", "react-router-dom"],
|
manualChunks: {
|
||||||
"ui-vendor": [
|
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||||
"@radix-ui/react-avatar",
|
'ui-vendor': ['@radix-ui/react-avatar', '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select'],
|
||||||
"@radix-ui/react-dialog",
|
'chart-vendor': ['recharts'],
|
||||||
"@radix-ui/react-dropdown-menu",
|
'query-vendor': ['@tanstack/react-query'],
|
||||||
"@radix-ui/react-select",
|
|
||||||
],
|
|
||||||
"chart-vendor": ["recharts"],
|
|
||||||
"query-vendor": ["@tanstack/react-query"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
chunkSizeWarningLimit: 1000,
|
|
||||||
},
|
},
|
||||||
server: {
|
chunkSizeWarningLimit: 1000,
|
||||||
port: 5173,
|
},
|
||||||
strictPort: false,
|
server: {
|
||||||
host: true,
|
port: 5173,
|
||||||
...(useApiProxy && {
|
strictPort: false,
|
||||||
proxy: {
|
host: true,
|
||||||
"/api": {
|
},
|
||||||
target: proxyTarget.replace(/\/$/, ""),
|
preview: {
|
||||||
changeOrigin: true,
|
port: 4173,
|
||||||
secure: true,
|
strictPort: false,
|
||||||
rewrite: (p) => p.replace(/^\/api/, "") || "/",
|
host: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
port: 4173,
|
|
||||||
strictPort: false,
|
|
||||||
host: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user